Merge pull request #665 from rowboatlabs/feature/apps-v1

Rowboat Apps V1 — M1 + M2 (spec implementation)
This commit is contained in:
gagan 2026-07-06 14:18:11 +05:30 committed by GitHub
commit b7d1019538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 3332 additions and 1317 deletions

View file

@ -315,3 +315,50 @@ export async function listToolkits() {
totalItems: filtered.length,
};
}
/**
* Execute a Composio tool by slug on behalf of a Mini App. The toolkit must be
* connected (ACTIVE). Mirrors the agent's composio-execute-tool builtin.
*/
export async function executeTool(
toolkitSlug: string,
toolSlug: string,
args?: Record<string, unknown>,
): Promise<{ successful: boolean; data?: unknown; error?: string }> {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return { successful: false, error: `Toolkit "${toolkitSlug}" is not connected.` };
}
try {
const result = await composioClient.executeAction(toolSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: args ?? {},
});
return { successful: result.successful, data: result.data, error: result.error ?? undefined };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Mini App tool execution failed for ${toolSlug}:`, message);
return { successful: false, error: `Failed to execute ${toolSlug}: ${message}` };
}
}
/**
* Search Composio tools within a toolkit so a Mini App can discover the right
* tool slug + input schema at runtime (how generated apps will wire actions).
*/
export async function searchToolsInToolkit(
toolkitSlug: string,
query: string,
): Promise<{ tools: Array<{ slug: string; name: string; description?: string }>; error?: string }> {
try {
const { items } = await composioClient.searchTools(query, [toolkitSlug]);
return {
tools: items.map((t) => ({ slug: t.slug, name: t.name, description: t.description })),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { tools: [], error: message };
}
}

View file

@ -62,6 +62,10 @@ import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, ge
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import * as composioHandler from './composio-handler.js';
import * as appsIndexer from '@x/core/dist/apps/indexer.js';
import * as appsServer from '@x/core/dist/apps/server.js';
import * as appsAgents from '@x/core/dist/apps/agents.js';
import { capture } from '@x/core/dist/analytics/posthog.js';
import { consumePendingDeepLink } from './deeplink.js';
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
@ -1544,9 +1548,56 @@ export function setupIpcHandlers() {
'composio:list-toolkits': async () => {
return composioHandler.listToolkits();
},
'composio:execute-tool': async (_event, args) => {
return composioHandler.executeTool(args.toolkitSlug, args.toolSlug, args.arguments);
},
'composio:search-tools': async (_event, args) => {
return composioHandler.searchToolsInToolkit(args.toolkitSlug, args.query);
},
'migration:check-composio-google': async () => {
return qualifyAndDisconnectComposioGoogle();
},
// Rowboat Apps handlers (spec §13)
'apps:list': async () => {
const status = appsServer.getServerStatus();
const apps = await appsIndexer.listApps();
// Keep bundled agents materialized (idempotent; disabled by default).
for (const app of apps) {
if (app.agentSlugs.length) await appsAgents.syncAppAgents(app);
}
return {
serverRunning: status.running,
...(status.error ? { serverError: status.error } : {}),
apps,
};
},
'apps:get': async (_event, args) => {
const app = await appsIndexer.getApp(args.folder);
if (!app) throw new Error(`no such app: ${args.folder}`);
const readme = await appsIndexer.readAppReadme(args.folder);
return {
app,
...(readme ? { readme } : {}),
rollbackAvailable: await appsIndexer.rollbackAvailable(args.folder),
};
},
'apps:create': async (_event, args) => {
const app = await appsIndexer.createApp(args);
capture('app_created', { folder: app.folder });
return { app };
},
'apps:delete': async (_event, args) => {
await appsIndexer.deleteApp(args.folder);
// Remove app-owned bg-tasks too — orphaned app--<folder>-- tasks firing
// against a deleted app was a painful prototype failure mode.
await appsAgents.deleteAppAgents(args.folder);
capture('app_deleted', { folder: args.folder });
return { ok: true as const };
},
'apps:setTheme': async (_event, args) => {
appsServer.setAppsTheme(args.theme);
return { ok: true as const };
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');

View file

@ -36,7 +36,8 @@ import { init as initEventProcessor, registerConsumer } from "@x/core/dist/event
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
@ -501,9 +502,11 @@ app.whenReady().then(async () => {
// start chrome extension sync server
initChromeSync();
// start local sites server for iframe dashboards and other mini apps
initLocalSites().catch((error) => {
console.error('[LocalSites] Failed to start:', error);
// start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) with the
// full Host API (tools/fetch/llm/copilot behind the capability gate)
registerAppsHostApi();
initAppsServer().catch((error) => {
console.error('[Apps] Failed to start:', error);
});
app.on("activate", () => {
@ -532,8 +535,8 @@ app.on("before-quit", () => {
}
// Kill embedded terminal shells.
disposeAllTerminals();
shutdownLocalSites().catch((error) => {
console.error('[LocalSites] Failed to shut down cleanly:', error);
shutdownAppsServer().catch((error) => {
console.error('[Apps] Failed to shut down cleanly:', error);
});
shutdownAnalytics().catch((error) => {
console.error('[Analytics] Failed to flush on quit:', error);

View file

@ -29,6 +29,7 @@ import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view';
import { AppsView } from '@/components/apps/apps-view';
import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view';
import { CodingRunBlock } from '@/components/coding-run';
@ -202,6 +203,7 @@ const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const MEETINGS_TAB_PATH = '__rowboat_meetings__'
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
const APPS_TAB_PATH = '__rowboat_mini_apps__'
const EMAIL_TAB_PATH = '__rowboat_email__'
const WORKSPACE_TAB_PATH = '__rowboat_workspace__'
const WORKSPACE_ROOT = 'knowledge/Workspace'
@ -378,6 +380,7 @@ const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB
const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
const isAppsTabPath = (path: string) => path === APPS_TAB_PATH
const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH
const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH
const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH
@ -640,6 +643,7 @@ type ViewState =
| { type: 'home' }
| { type: 'code' }
| { type: 'bg-tasks' }
| { type: 'apps' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@ -718,6 +722,8 @@ function parseDeepLink(input: string): ViewState | null {
return { type: 'code' }
case 'bg-tasks':
return { type: 'bg-tasks' }
case 'apps':
return { type: 'apps' }
default:
return null
}
@ -829,6 +835,7 @@ function App() {
const [isMeetingsOpen, setIsMeetingsOpen] = useState(false)
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
const [isAppsOpen, setIsAppsOpen] = useState(false)
const [isEmailOpen, setIsEmailOpen] = useState(false)
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
@ -1556,6 +1563,7 @@ function App() {
if (isMeetingsTabPath(tab.path)) return 'Meetings'
if (isLiveNotesTabPath(tab.path)) return 'Live notes'
if (isBgTasksTabPath(tab.path)) return 'Background tasks'
if (isAppsTabPath(tab.path)) return 'Mini Apps'
if (isEmailTabPath(tab.path)) return 'Email'
if (isWorkspaceTabPath(tab.path)) return 'Workspace'
if (isKnowledgeViewTabPath(tab.path)) return 'Brain'
@ -2322,6 +2330,9 @@ function App() {
}>>([])
const [bgTaskInitialSlug, setBgTaskInitialSlug] = useState<string | null>(null)
const [bgTaskSlugVersion, setBgTaskSlugVersion] = useState(0)
// Mini App to auto-open in the Mini Apps view (set by app-navigation open-app).
const [appInitialId, setAppInitialId] = useState<string | null>(null)
const [appIdVersion, setAppIdVersion] = useState(0)
const loadBgTaskSummaries = useCallback(async () => {
try {
@ -3429,7 +3440,7 @@ function App() {
setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedPath(path)
return
}
@ -3438,7 +3449,7 @@ function App() {
setActiveFileTabId(id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay])
@ -3457,14 +3468,14 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
return
}
if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
return
}
if (isLiveNotesTabPath(tab.path)) {
@ -3477,7 +3488,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsLiveNotesOpen(true)
return
}
@ -3491,10 +3502,25 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsBgTasksOpen(true)
return
}
if (isAppsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsAppsOpen(true)
return
}
if (isMeetingsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
@ -3506,7 +3532,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
return
}
if (isEmailTabPath(tab.path)) {
@ -3519,7 +3545,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsEmailOpen(true)
return
}
@ -3533,7 +3559,7 @@ function App() {
setIsEmailOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsWorkspaceOpen(true)
return
}
@ -3547,7 +3573,7 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsKnowledgeViewOpen(true)
return
}
@ -3561,7 +3587,7 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(true); setIsHomeOpen(false)
setIsChatHistoryOpen(true); setIsHomeOpen(false); setIsAppsOpen(false)
return
}
if (isHomeTabPath(tab.path)) {
@ -3569,7 +3595,7 @@ function App() {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setIsHomeOpen(true)
setIsHomeOpen(true); setIsAppsOpen(false)
return
}
if (isCodeTabPath(tab.path)) {
@ -3577,18 +3603,18 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
return
}
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId)
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isCodeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isAppsTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isCodeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path)
@ -3611,7 +3637,7 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
@ -3625,12 +3651,12 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
} else if (isMeetingsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
@ -3642,7 +3668,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
} else if (isLiveNotesTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
@ -3653,7 +3679,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsLiveNotesOpen(true)
} else if (isBgTasksTabPath(newActiveTab.path)) {
setSelectedPath(null)
@ -3666,7 +3692,20 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
} else if (isAppsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsAppsOpen(true)
} else if (isEmailTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
@ -3677,7 +3716,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsEmailOpen(true)
} else if (isWorkspaceTabPath(newActiveTab.path)) {
setSelectedPath(null)
@ -3689,7 +3728,7 @@ function App() {
setIsEmailOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsWorkspaceOpen(true)
} else if (isKnowledgeViewTabPath(newActiveTab.path)) {
setSelectedPath(null)
@ -3701,7 +3740,7 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsKnowledgeViewOpen(true)
} else if (isChatHistoryTabPath(newActiveTab.path)) {
setSelectedPath(null)
@ -3713,17 +3752,17 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(true); setIsHomeOpen(false)
setIsChatHistoryOpen(true); setIsHomeOpen(false); setIsAppsOpen(false)
} else if (isHomeTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setIsHomeOpen(true)
setIsHomeOpen(true); setIsAppsOpen(false)
} else {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedPath(newActiveTab.path)
}
}
@ -3745,7 +3784,7 @@ function App() {
dismissBrowserOverlay()
handleNewChat()
// Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
@ -3762,8 +3801,8 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
}, [dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen])
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
}, [dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isAppsOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen])
// Sidebar variant: reset the chat in place without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => {
@ -3780,6 +3819,13 @@ function App() {
setPendingPaletteSubmit({ text, mention })
}, [isChatSidebarOpen, handleNewChatTabInSidebar])
// Open the chat sidebar on a fresh tab and pre-fill (not send) a builder prompt.
const prefillChat = useCallback((text: string) => {
if (!isChatSidebarOpen) setIsChatSidebarOpen(true)
handleNewChatTabInSidebar()
setPresetMessage(text)
}, [isChatSidebarOpen, handleNewChatTabInSidebar])
useEffect(() => {
if (!pendingPaletteSubmit) return
const fileMention: FileMention | undefined = pendingPaletteSubmit.mention
@ -3895,7 +3941,7 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
@ -3911,8 +3957,8 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay])
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isAppsOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback((): boolean => {
let restored = false
@ -3921,11 +3967,11 @@ function App() {
if (expandedFrom.graph) {
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
} else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
} else if (expandedFrom.meetings) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
@ -3957,7 +4003,7 @@ function App() {
} else if (expandedFrom.path) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedPath(expandedFrom.path)
} else {
// expandedFrom was captured from a view this restorer doesn't track
@ -3983,10 +4029,11 @@ function App() {
if (isHomeOpen) return { type: 'home' }
if (isCodeOpen) return { type: 'code' }
if (isBgTasksOpen) return { type: 'bg-tasks' }
if (isAppsOpen) return { type: 'apps' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isAppsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@ -4076,6 +4123,17 @@ function App() {
setActiveFileTabId(id)
}, [fileTabs])
const ensureAppsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isAppsTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: APPS_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const ensureEmailFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isEmailTabPath(tab.path))
if (existing) {
@ -4153,7 +4211,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
@ -4170,7 +4228,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
@ -4178,6 +4236,19 @@ function App() {
ensureBgTasksFileTab()
}, [ensureBgTasksFileTab])
const openAppsView = useCallback(() => {
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setIsAppsOpen(true)
ensureAppsFileTab()
}, [ensureAppsFileTab])
const openMeetingsView = useCallback(() => {
setSelectedPath(null)
setIsGraphOpen(false)
@ -4190,7 +4261,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
@ -4202,7 +4273,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
@ -4218,7 +4289,7 @@ function App() {
// visible in the middle pane.
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file.
@ -4233,7 +4304,7 @@ function App() {
setSelectedPath(null)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@ -4246,7 +4317,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
@ -4259,7 +4330,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
ensureSuggestedTopicsFileTab()
return
case 'meetings':
@ -4277,7 +4348,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
ensureMeetingsFileTab()
return
case 'live-notes':
@ -4294,7 +4365,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setIsLiveNotesOpen(true)
ensureLiveNotesFileTab()
return
@ -4313,7 +4384,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
// Deep links (e.g. a new-email notification) carry the thread to open;
// bump the version so EmailView re-selects it even if email is already open.
if (view.threadId) {
@ -4341,7 +4412,7 @@ function App() {
setIsWorkspaceOpen(true)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
setWorkspaceInitialPath(view.path ?? null)
ensureWorkspaceFileTab()
return
@ -4362,7 +4433,7 @@ function App() {
setKnowledgeViewMode(view.mode ?? (view.folderPath ? 'files' : 'graph'))
setKnowledgeViewFolderPath(view.folderPath ?? null)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
setIsHomeOpen(false); setIsAppsOpen(false)
ensureKnowledgeViewFileTab()
return
case 'chat-history':
@ -4379,7 +4450,7 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(true); setIsHomeOpen(false)
setIsChatHistoryOpen(true); setIsHomeOpen(false); setIsAppsOpen(false)
ensureChatHistoryFileTab()
return
case 'home':
@ -4397,7 +4468,7 @@ function App() {
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsHomeOpen(true)
setIsHomeOpen(true); setIsAppsOpen(false)
ensureHomeFileTab()
return
case 'code':
@ -4408,7 +4479,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
ensureCodeFileTab()
return
case 'bg-tasks':
@ -4419,10 +4490,22 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
setIsBgTasksOpen(true)
ensureBgTasksFileTab()
return
case 'apps':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsAppsOpen(true)
ensureAppsFileTab()
return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
@ -4431,7 +4514,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false); setIsAppsOpen(false)
if (view.runId) {
const targetRunId = view.runId
// Bind the loaded run to a chat tab so its title (derived from
@ -4451,7 +4534,7 @@ function App() {
}
return
}
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, ensureBgTasksFileTab, handleNewChat, isRightPaneMaximized, loadRun])
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, ensureBgTasksFileTab, ensureAppsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
@ -4560,6 +4643,19 @@ function App() {
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
}, [])
// Report the UI theme to the apps server (spec §7.1): apps read it from
// GET /_rowboat/app and get live changes via the SSE theme event.
useEffect(() => {
const report = () => {
const theme = document.documentElement.classList.contains('dark') ? 'dark' as const : 'light' as const
void window.ipc.invoke('apps:setTheme', { theme }).catch(() => { /* server may be down */ })
}
report()
const observer = new MutationObserver(report)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
// Triggered by main when the user clicks a calendar-meeting notification.
// Reuses the same flow as the in-app "Join meeting & take notes" button.
// When `openMeeting` is true, also opens the meeting URL in the system browser.
@ -4699,6 +4795,13 @@ function App() {
}
break
}
case 'open-app':
if (result.appId) {
setAppInitialId(result.appId as string)
setAppIdVersion((v) => v + 1)
openAppsView()
}
break
case 'update-base-view': {
// Navigate to bases if not already there
const targetPath = selectedPath && isBaseFilePath(selectedPath) ? selectedPath : BASES_DEFAULT_TAB_PATH
@ -4865,7 +4968,7 @@ function App() {
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !isCodeOpen && !selectedBackgroundTask && !isBrowserOpen
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isAppsOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !isCodeOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -4950,11 +5053,11 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && isChatSidebarOpen)
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen)
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen)
const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH
: isSuggestedTopicsOpen
@ -4965,6 +5068,8 @@ function App() {
? LIVE_NOTES_TAB_PATH
: isBgTasksOpen
? BG_TASKS_TAB_PATH
: isAppsOpen
? APPS_TAB_PATH
: isEmailOpen
? EMAIL_TAB_PATH
: isWorkspaceOpen
@ -5048,7 +5153,7 @@ function App() {
}
document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isAppsOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
@ -5073,7 +5178,7 @@ function App() {
}),
},
}))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isAppsOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -5212,21 +5317,21 @@ function App() {
},
openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isAppsOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'graph' })
},
openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isAppsOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
},
openWorkspaceAt: (path?: string) => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isAppsOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -5949,7 +6054,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || isBrowserOpen)
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const nonChatPaneStyle = React.useMemo<React.CSSProperties>(() => {
@ -5997,7 +6102,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen) {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isAppsOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
}}>
@ -6020,6 +6125,7 @@ function App() {
: isCodeOpen ? 'code'
: (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge'
: isBgTasksOpen ? 'agents'
: isAppsOpen ? 'apps'
: isWorkspaceOpen ? 'workspaces'
: null
}
@ -6027,6 +6133,7 @@ function App() {
onOpenCode={openCodeView}
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenApps={openAppsView}
recentRuns={runs}
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
@ -6060,7 +6167,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen) && fileTabs.length >= 1 ? (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen) && fileTabs.length >= 1 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -6068,7 +6175,7 @@ function App() {
getTabId={(t) => t.id}
onSwitchTab={switchFileTab}
onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/>
) : isFullScreenChat ? (
<ChatHeader
@ -6133,7 +6240,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isCodeOpen && !selectedTask && !isBrowserOpen && (
{!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isAppsOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isCodeOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -6153,7 +6260,7 @@ function App() {
a freshly-mounted no-drag button inside the drag-region header
otherwise has its first click swallowed by the window drag. */}
{(() => {
const viewOpen = selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen
const viewOpen = selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isAppsOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen
const action = isFullScreenChat
? { onClick: pushChatToSidePane, icon: <ArrowRight className="size-5" />, label: 'Dock chat to side pane' }
: (viewOpen && !isChatSidebarOpen)
@ -6257,6 +6364,14 @@ function App() {
}}
/>
</div>
) : isAppsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<AppsView
initialAppFolder={appInitialId}
initialVersion={appIdVersion}
onNewApp={() => prefillChat('Build me an app that ')}
/>
</div>
) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} initialSearchQuery={emailInitialSearchQuery} searchQueryVersion={emailSearchQueryVersion} />

View file

@ -0,0 +1,186 @@
import { useEffect, useState } from 'react'
import { X, RotateCcw, Play } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
// App detail panel (spec §14): manifest info, provenance/publish state,
// bundled agents with enable toggles, rollback when available. Update/publish
// actions land with M3.
type AgentRow = {
slug: string
name: string
active: boolean
lastRunAt?: string
lastRunError?: string
}
export function AppDetail({ folder, onClose }: { folder: string; onClose: () => void }) {
const [app, setApp] = useState<rowboatApp.AppSummary | null>(null)
const [readme, setReadme] = useState<string | undefined>(undefined)
const [rollback, setRollback] = useState(false)
const [agents, setAgents] = useState<AgentRow[]>([])
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const r = await window.ipc.invoke('apps:get', { folder })
if (cancelled) return
setApp(r.app)
setReadme(r.readme)
setRollback(r.rollbackAvailable)
const rows: AgentRow[] = []
for (const slug of r.app.agentSlugs) {
try {
const t = await window.ipc.invoke('bg-task:get', { slug })
if (t.task) {
rows.push({
slug,
name: t.task.name,
active: t.task.active,
lastRunAt: t.task.lastRunAt,
lastRunError: t.task.lastRunError,
})
}
} catch { /* not materialized yet */ }
}
if (!cancelled) setAgents(rows)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
}
})()
return () => { cancelled = true }
}, [folder, reloadNonce])
const toggleAgent = async (slug: string, active: boolean) => {
setAgents((prev) => prev.map((a) => (a.slug === slug ? { ...a, active } : a)))
try {
await window.ipc.invoke('bg-task:patch', { slug, partial: { active } })
} catch {
setReloadNonce((n) => n + 1) // revert to truth on failure
}
}
const runAgent = async (slug: string) => {
try {
await window.ipc.invoke('bg-task:run', { slug })
} catch { /* surfaced via bg-task UI */ }
}
const manifest = app?.manifest
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="flex-1 truncate text-sm font-semibold">{manifest?.name ?? folder}</span>
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 text-sm">
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">{error}</div>}
{!app ? (
<div className="text-muted-foreground">Loading</div>
) : (
<div className="space-y-5">
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">App</div>
{app.status === 'invalid' && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Invalid manifest: {app.manifestError}
</div>
)}
<InfoRow k="Version" v={manifest ? `v${manifest.version}` : '—'} />
<InfoRow k="Folder" v={app.folder} />
<InfoRow k="Origin" v={app.origin} mono />
{manifest?.description ? <p className="pt-1 text-muted-foreground">{manifest.description}</p> : null}
</section>
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Capabilities</div>
{manifest && manifest.capabilities.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{manifest.capabilities.map((c) => (
<span key={c} className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground">{c}</span>
))}
</div>
) : (
<p className="text-muted-foreground">None declared this app cant use tools, LLM, or the copilot.</p>
)}
</section>
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Source</div>
{app.kind === 'installed' && app.install ? (
<>
<InfoRow k="Installed from" v={app.install.repo ?? app.install.sourceUrl ?? 'unknown'} mono />
<InfoRow k="Installed" v={new Date(app.install.installedAt).toLocaleString()} />
{app.install.updatedAt && <InfoRow k="Updated" v={new Date(app.install.updatedAt).toLocaleString()} />}
</>
) : (
<p className="text-muted-foreground">Local app created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.</p>
)}
{rollback && (
<button type="button" className="mt-1 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent">
<RotateCcw className="size-3.5" /> Roll back to previous version
</button>
)}
</section>
<section className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Background agents</div>
{agents.length === 0 ? (
<p className="text-muted-foreground">No bundled agents.</p>
) : agents.map((a) => (
<div key={a.slug} className="flex items-center gap-2 rounded-lg border border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{a.name}</div>
<div className="truncate text-xs text-muted-foreground">
{a.lastRunError ? `Failed: ${a.lastRunError}` : a.lastRunAt ? `Last run ${new Date(a.lastRunAt).toLocaleString()}` : 'Never run'}
</div>
</div>
<button
type="button"
title="Run now"
onClick={() => void runAgent(a.slug)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Play className="size-3.5" />
</button>
<button
type="button"
role="switch"
aria-checked={a.active}
onClick={() => void toggleAgent(a.slug, !a.active)}
className={`relative h-5 w-9 rounded-full transition ${a.active ? 'bg-primary' : 'bg-muted'}`}
>
<span className={`absolute top-0.5 size-4 rounded-full bg-background shadow transition-all ${a.active ? 'left-[18px]' : 'left-0.5'}`} />
</button>
</div>
))}
</section>
{readme && (
<section className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">README</div>
<pre className="whitespace-pre-wrap rounded-lg border border-border bg-muted/40 p-3 text-xs leading-relaxed">{readme}</pre>
</section>
)}
</div>
)}
</div>
</div>
)
}
function InfoRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
return (
<div className="flex items-baseline gap-2">
<span className="w-28 shrink-0 text-xs text-muted-foreground">{k}</span>
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
</div>
)
}

View file

@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import { ArrowLeft, ExternalLink, Info, RotateCw } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { appOpened } from '@/lib/analytics'
import { AppDetail } from '@/components/apps/app-detail'
// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr —
// per-app browser origins are the isolation boundary. Toolbar: back, reload,
// open-in-browser, detail panel.
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
const [reloadNonce, setReloadNonce] = useState(0)
const [showDetail, setShowDetail] = useState(false)
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
// surface a visible retry state instead of a silent blank pane.
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
const title = app.manifest?.name ?? app.folder
useEffect(() => {
appOpened(app.folder)
}, [app.folder])
// Reset the watchdog when the target changes (adjust-during-render pattern).
const [watchKey, setWatchKey] = useState(`${app.folder}:${reloadNonce}`)
if (watchKey !== `${app.folder}:${reloadNonce}`) {
setWatchKey(`${app.folder}:${reloadNonce}`)
setLoadState('loading')
}
useEffect(() => {
const timer = window.setTimeout(() => {
setLoadState((s) => (s === 'loading' ? 'stuck' : s))
}, 6000)
return () => window.clearTimeout(timer)
}, [watchKey])
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ArrowLeft className="size-4" />
Apps
</button>
<span className="flex-1 truncate text-sm font-medium">{title}</span>
<button
type="button"
title="Reload"
onClick={() => setReloadNonce((n) => n + 1)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RotateCw className="size-4" />
</button>
<button
type="button"
title="Open in browser"
onClick={() => window.open(app.origin, '_blank')}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ExternalLink className="size-4" />
</button>
<button
type="button"
title="App details"
onClick={() => setShowDetail((v) => !v)}
className={`rounded-md p-1.5 hover:bg-accent hover:text-foreground ${showDetail ? 'text-foreground' : 'text-muted-foreground'}`}
>
<Info className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1">
<div className="relative min-w-0 flex-1">
<iframe
key={reloadNonce}
title={title}
src={`${app.origin}/`}
onLoad={() => setLoadState('ok')}
className="h-full w-full border-0 bg-background"
/>
{loadState === 'stuck' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
<div className="text-muted-foreground">This app is taking too long to load.</div>
<button
type="button"
onClick={() => setReloadNonce((n) => n + 1)}
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
>
Retry
</button>
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
</div>
)}
</div>
{showDetail && (
<div className="w-80 shrink-0 border-l border-border">
<AppDetail folder={app.folder} onClose={() => setShowDetail(false)} />
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,238 @@
import { useEffect, useState } from 'react'
import { Plus, RefreshCw } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { AppFrame } from '@/components/apps/app-frame'
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
// AppSummary-driven; click opens the app full-height on its own origin.
type Theme = { accent: string; glow: string }
const THEMES: Theme[] = [
{ accent: '#FF4D8D', glow: 'rgba(255,77,141,0.45)' }, // Pink
{ accent: '#EF4444', glow: 'rgba(239,68,68,0.45)' }, // Red
{ accent: '#22C55E', glow: 'rgba(34,197,94,0.40)' }, // Emerald
{ accent: '#F59E0B', glow: 'rgba(245,158,11,0.42)' }, // Amber
{ accent: '#14B8A6', glow: 'rgba(20,184,166,0.40)' }, // Teal
{ accent: '#EC4899', glow: 'rgba(236,72,153,0.42)' }, // Rose
]
const PATTERNS = ['dots', 'grid', 'diagonal', 'radial', 'waves', 'mesh', 'cross', 'rings', 'zigzag', 'plus', 'checker', 'beams']
function hash(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
return Math.abs(h)
}
const themeForIndex = (i: number): Theme => THEMES[i % THEMES.length]
const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length]
const CARD_CSS = `
.ma-page {
container-type: inline-size;
--ma-bg:#f8f8f9;
--ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee;
--ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1;
--ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9);
--ma-border:rgba(0,0,0,0.09); --ma-border-hover:rgba(0,0,0,0.15);
--ma-shadow:0 1px 2px rgba(0,0,0,0.08);
--ma-title:#0d0e11; --ma-desc:rgba(0,0,0,0.6);
--ma-h1:#0d0e11; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42);
--ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5);
--ma-new-border:rgba(0,0,0,0.14); --ma-new-title:rgba(0,0,0,0.6); --ma-new-hint:rgba(0,0,0,0.4);
--ma-pat-opacity:0.10; --ma-glow-opacity:0.16; --ma-glow-hover-opacity:0.24;
--ma-badge-mix:20%; --ma-pill-mix:16%; --ma-tint:16%; --ma-tint-hover:22%;
height:100%; overflow:auto; background:var(--ma-bg);
}
.dark .ma-page {
--ma-bg:#0b0b0d;
--ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116;
--ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419;
--ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09);
--ma-border:rgba(255,255,255,0.07); --ma-border-hover:rgba(255,255,255,0.12);
--ma-shadow:0 1px 2px rgba(0,0,0,0.35);
--ma-title:#f4f5f7; --ma-desc:rgba(255,255,255,0.66);
--ma-h1:#f4f5f7; --ma-sub:rgba(255,255,255,0.52); --ma-lastrun:rgba(255,255,255,0.38);
--ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5);
--ma-new-border:rgba(255,255,255,0.12); --ma-new-title:rgba(255,255,255,0.6); --ma-new-hint:rgba(255,255,255,0.38);
--ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16;
--ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%;
}
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(14px,2cqw,20px); }
.ma-tabs { display:flex; gap:6px; margin-bottom:clamp(14px,2cqw,22px); }
.ma-tab { border:1px solid var(--ma-border); background:transparent; color:var(--ma-sub); border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }
.ma-tab.on { color:var(--ma-title); border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--ma-title) 6%, transparent); }
.ma-banner { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:var(--ma-title); border-radius:12px; padding:10px 14px; font-size:13px; margin-bottom:16px; }
.ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); }
.ma-card {
position:relative; min-height:clamp(190px,24cqw,244px); border-radius:18px;
border:1px solid var(--ma-border);
background:
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 34%),
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint), transparent) 0%, transparent 62%),
linear-gradient(158deg, var(--ma-card-from) 0%, var(--ma-card-mid) 52%, var(--ma-card-to) 100%);
padding:clamp(15px,2cqw,22px); text-align:left; cursor:pointer; overflow:hidden;
display:flex; flex-direction:column; isolation:isolate;
box-shadow: var(--ma-shadow), inset 0 1px 0 var(--ma-top-highlight), 0 8px 22px -20px var(--glow);
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease;
}
.ma-card:hover {
border-color: var(--ma-border-hover);
background:
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 36%),
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint-hover), transparent) 0%, transparent 64%),
linear-gradient(158deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-mid) 52%, var(--ma-card-hover-to) 100%);
}
.ma-card::before { content:''; position:absolute; inset:0; z-index:-1; opacity:var(--ma-pat-opacity); pointer-events:none; }
.ma-card::after {
content:''; position:absolute; top:-45%; right:-25%; width:75%; height:75%; z-index:-1;
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
opacity:var(--ma-glow-opacity); filter: blur(18px); pointer-events:none; transition: opacity .22s ease;
}
.ma-card:hover::after { opacity:var(--ma-glow-hover-opacity); }
.ma-pat-dots::before { background-image: radial-gradient(var(--accent) 1px, transparent 1.4px); background-size:16px 16px; }
.ma-pat-grid::before { background-image: linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px); background-size:26px 26px; }
.ma-pat-diagonal::before { background-image: repeating-linear-gradient(45deg, var(--accent) 0 1px, transparent 1px 14px); }
.ma-pat-radial::before { background-image: radial-gradient(circle at 78% 18%, var(--accent) 0%, transparent 55%); opacity:calc(var(--ma-pat-opacity) + 0.05); }
.ma-pat-waves::before { background-image: repeating-radial-gradient(circle at 50% -30%, transparent 0 20px, var(--accent) 20px 21px); }
.ma-pat-mesh::before { background-image: radial-gradient(circle at 12% 18%, var(--accent) 0%, transparent 42%), radial-gradient(circle at 88% 82%, var(--accent) 0%, transparent 42%); opacity:calc(var(--ma-pat-opacity) + 0.03); }
.ma-pat-cross::before { background-image: repeating-linear-gradient(45deg, var(--accent) 0 1px, transparent 1px 18px), repeating-linear-gradient(-45deg, var(--accent) 0 1px, transparent 1px 18px); }
.ma-pat-rings::before { background-image: repeating-radial-gradient(circle at 82% 20%, transparent 0 14px, var(--accent) 14px 15px); }
.ma-pat-zigzag::before { background-image: linear-gradient(135deg, var(--accent) 25%, transparent 25%), linear-gradient(225deg, var(--accent) 25%, transparent 25%); background-size: 22px 12px; background-position: 0 0, 11px 0; opacity:calc(var(--ma-pat-opacity) - 0.03); }
.ma-pat-plus::before { background-image: radial-gradient(var(--accent) 0.8px, transparent 1px), linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px); background-size: 24px 24px, 24px 24px, 24px 24px; background-position: 12px 12px, 0 11.5px, 11.5px 0; opacity:calc(var(--ma-pat-opacity) - 0.02); }
.ma-pat-checker::before { background-image: repeating-conic-gradient(var(--accent) 0% 25%, transparent 0% 50%); background-size: 26px 26px; opacity:calc(var(--ma-pat-opacity) - 0.04); }
.ma-pat-beams::before { background-image: repeating-linear-gradient(100deg, var(--accent) 0 2px, transparent 2px 34px); }
.ma-top { display:flex; justify-content:flex-end; gap:6px; }
.ma-badge {
display:inline-flex; align-items:center; height:22px; padding:0 10px; border-radius:999px;
font-size:9.5px; font-weight:600; letter-spacing:0.07em;
color: var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-badge-mix), transparent);
}
.ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); }
.ma-badge.err { color:#ef4444; background:rgba(239,68,68,.14); }
.ma-title { font-size:clamp(17px,2.3cqw,21px); font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:clamp(12px,2cqw,18px) 0 8px; }
.ma-desc {
font-size:clamp(13px,1.5cqw,14.5px); font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0;
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
.ma-footer { margin-top:auto; padding-top:clamp(14px,2cqw,22px); display:flex; align-items:center; justify-content:space-between; gap:10px; }
.ma-source { font-size:11.5px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 10px; border-radius:999px; white-space:nowrap; }
.ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ma-new {
width:100%; font:inherit; min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:8px; color:var(--ma-new-title); cursor:pointer; transition: border-color .2s ease, background .2s ease;
}
.ma-new:hover { border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--accent, #888) 6%, transparent); }
.ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); }
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; }
.ma-empty { padding:36px 8px; text-align:center; color:var(--ma-sub); font-size:14px; grid-column:1/-1; }
@container (max-width: 380px) {
.ma-footer { flex-direction:column; align-items:flex-start; gap:6px; }
}
`
function Card({ app, index, onOpen }: { app: rowboatApp.AppSummary; index: number; onOpen: () => void }) {
const theme = themeForIndex(index)
const pattern = patternFor(app.folder)
const invalid = app.status === 'invalid'
return (
<button
type="button"
onClick={onOpen}
title={invalid ? app.manifestError : undefined}
className={`ma-card ma-pat-${pattern}`}
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
>
<div className="ma-top">
{invalid && <span className="ma-badge err">INVALID</span>}
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
</span>
</div>
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
<div className="ma-footer">
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
<span className="ma-lastrun">{app.folder}</span>
</div>
</button>
)
}
export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
initialAppFolder?: string | null
initialVersion?: number
onNewApp?: () => void
} = {}) {
const [tab, setTab] = useState<'mine' | 'catalog'>('mine')
const [selectedFolder, setSelectedFolder] = useState<string | null>(initialAppFolder ?? null)
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
const [serverError, setServerError] = useState<string | null>(null)
// Open a specific app when asked from outside (app-navigation open-app).
const [appliedVersion, setAppliedVersion] = useState(initialVersion)
if (initialVersion !== appliedVersion) {
setAppliedVersion(initialVersion)
if (initialAppFolder) setSelectedFolder(initialAppFolder)
}
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const r = await window.ipc.invoke('apps:list', {})
if (cancelled) return
setApps(r.apps)
setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
} catch (e) {
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
}
}
void load()
const interval = setInterval(load, 4000) // keep the grid fresh (copilot installs)
return () => { cancelled = true; clearInterval(interval) }
}, [initialVersion])
const selected = selectedFolder ? apps.find((a) => a.folder === selectedFolder) : undefined
if (selected) {
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
}
return (
<div className="ma-page">
<style>{CARD_CSS}</style>
<div className="ma-inner">
<h1 className="ma-h1">Apps</h1>
<p className="ma-sub">Apps that live inside Rowboat, powered by your agents and integrations.</p>
<div className="ma-tabs">
<button type="button" className={`ma-tab${tab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
<button type="button" className={`ma-tab${tab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
</div>
{serverError && (
<div className="ma-banner">
<RefreshCw className="mr-1.5 inline size-3.5" /> Apps server unavailable: {serverError}
</div>
)}
{tab === 'catalog' ? (
<div className="ma-empty">The community catalog is coming soon.</div>
) : (
<div className="ma-grid">
{apps.map((app, i) => (
<Card key={app.folder} app={app} index={i} onOpen={() => setSelectedFolder(app.folder)} />
))}
<button type="button" className="ma-new" onClick={onNewApp}>
<Plus className="size-5" />
<div className="ma-new-title">New app</div>
<div className="ma-new-hint">Describe one to the copilot</div>
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -13,6 +13,7 @@ import {
Globe,
AlertTriangle,
Home,
LayoutGrid,
Mic,
SquarePen,
Plug,
@ -166,6 +167,7 @@ type SidebarContentPanelProps = {
onOpenMeetings?: () => void
onOpenCode?: () => void
onOpenBgTasks?: () => void
onOpenApps?: () => void
onOpenAgent?: (slug: string) => void
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
onOpenRun?: (runId: string) => void
@ -178,7 +180,7 @@ type SidebarContentPanelProps = {
/** Starts the mascot-guided product tour. */
onStartTour?: () => void
/** Which primary destination is currently active, for nav highlighting. */
activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'workspaces' | null
activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'apps' | 'workspaces' | null
/** Live meeting recording state, so the recording row can show its indicator/stop. */
meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping'
recordingMeetingSource?: string | null
@ -417,6 +419,7 @@ export function SidebarContentPanel({
onOpenMeetings,
onOpenCode,
onOpenBgTasks,
onOpenApps,
recentRuns = [],
onOpenRun,
onOpenChatHistory,
@ -908,6 +911,15 @@ export function SidebarContentPanel({
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={activeNav === 'apps'}
onClick={onOpenApps}
>
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-muted-foreground">Apps</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-tour-id="nav-workspaces"

View file

@ -53,6 +53,10 @@ export function chatMessageSent(props: {
})
}
export function appOpened(folder: string) {
posthog.capture('app_opened', { folder })
}
export function oauthConnected(provider: string) {
posthog.capture('oauth_connected', { provider })
}

View file

@ -242,6 +242,7 @@ export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null =
switch (action) {
case 'open-note': return { action, label: `Opening ${(input.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'}...` }
case 'open-view': return { action, label: `Opening ${appViewLabel(input.view)}...` }
case 'open-app': return { action, label: `Opening ${input.appId || 'app'}...` }
case 'read-view': return { action, label: `Reading ${appViewLabel(input.view)}...` }
case 'open-item': return { action, label: 'Opening...' }
case 'update-base-view': return { action, label: 'Updating view...' }
@ -259,6 +260,8 @@ export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null =
}
case 'open-view':
return { action: 'open-view', label: `Opened ${appViewLabel(result.view)}` }
case 'open-app':
return { action: 'open-app', label: `Opened ${result.appName || result.appId || 'app'}` }
case 'read-view': {
const counted =
(result.threads as unknown[] | undefined)?.length ??