diff --git a/apps/x/MINI_APPS_PLAN.md b/apps/x/MINI_APPS_PLAN.md new file mode 100644 index 00000000..72a82a29 --- /dev/null +++ b/apps/x/MINI_APPS_PLAN.md @@ -0,0 +1,307 @@ +# Mini Apps — Implementation Plan + +> Status: **Phase 1 (UI + rendering, hand-coded apps)** in progress. +> Branch: `feature/mini-apps`. Working dir: `apps/x`. + +## 1. What we're building (agreed design) + +A **Mini App** is a user-created, personal app that lives inside Rowboat under a +**"Mini Apps"** sidebar tab, shown as cards; click a card to open and use it like +a standalone app. + +The settled mental model: + +> A Mini App = **custom UI code** (a single self-contained HTML file: React + +> Tailwind, no build step, runs in a sandboxed iframe) + an **agent "backend"** +> that produces fresh **data** on a trigger + an optional **state store** + a +> **scoped bridge** that lets the UI call real **Composio** actions. + +Key decisions (the "why" behind the architecture): + +- **Opens to a finished, populated screen.** The agent runs *before* open (on a + trigger); the UI just renders the latest result. Maps onto the existing + background-agents engine. +- **Frontend/backend split.** Code = frontend, written once. Agent = backend, + produces fresh `data.json` each run. The UI is *not* regenerated per run. + Cheaper, faster, and the UI can't break on refresh. +- **A data contract** ties the three pieces together: at creation the builder + produces a matched triple — UI code + data schema + agent instructions. +- **Fully custom UI per app** (generated code), not a fixed block kit. +- **Real interactivity** via a **scoped bridge** to Composio (each app declares + the integrations it may touch; scope drives both enforcement and the auth + prompt). +- **Built by whatever engine is selected in the chat box** — Code Mode + (Claude Code / Codex) if the user has it, else the Copilot. +- **Living apps:** refined by chatting; breakage is first-class (app surfaces + errors → "fix with copilot"). +- **State is an optional provided primitive** (per-app persistent store the agent + and UI can read/write). Stateless apps ignore it. +- **Personal/local only** for now, but each app is a **self-contained folder** + so it can become portable later. + +### Framework for the apps + +Single self-contained `index.html` rendered in a sandboxed iframe. + +> **Learning (Phase 1):** remote CDNs do NOT work inside the sandboxed, +> opaque-origin `srcdoc` iframe (React/Tailwind/Babel from unpkg/cdn.tailwindcss +> failed → blank screen). Apps must be **fully self-contained, no network**. +> Phase 1 therefore ships **vanilla HTML + CSS + JS** (no build, no transpile). +> The React+Tailwind LLM-friendly target still stands for generation — but via a +> **locally-bundled** runtime (esbuild-at-save, libs injected into the HTML), +> never remote CDNs. The `window.rowboat` bridge is unchanged either way. + +The UI talks to the host through a `window.rowboat` **bridge** (the product +surface *and* the security boundary): + +``` +rowboat.getData(): Promise // latest agent output +rowboat.onData(cb): void // re-render on refresh +rowboat.getState() / setState(patch) // per-app persistent store +rowboat.callAction(scope, action, args) // scoped Composio call +rowboat.ready() // handshake: "send me data" +``` + +## 2. Phased roadmap + +1. **Surface + one real app (UI-first — THIS PHASE).** "Mini Apps" sidebar tab, + card grid, click to open. One **hardcoded** app (Twitter client) rendered in a + sandboxed iframe from a self-contained HTML file, reading static data through a + minimal bridge. Proves the *experience* and the *rendering path*. No agent, + no storage, no real Composio yet. +2. **The scoped bridge.** Real interactive buttons → real Composio actions, + enforced against a per-app manifest scope; auth prompt on demand. +3. **Generation.** Copilot/Code-Mode generates the UI+schema+agent triple from a + chat description. Rides the existing chat mode selector + `code-with-agents`. +4. **Living apps + breakage recovery.** Conversational edits; error surfacing. +5. **Scale & polish.** Context-overload windowing (e.g. 100 tweets); app + notifications (reuse `notify-user`). +6. **(V2)** Drag an app into the main workspace (macOS-style). + +## 2b. Phase 2.5 — On-disk apps + `app://miniapp` serving (CURRENT) + +Move built-in apps out of source into `~/.rowboat/apps//`, served as static +assets, with an optional background agent producing `data.json`. Sets up the +copilot builder path (it will write the same folder layout). Team-agreed. + +### On-disk layout (one folder per app) +``` +~/.rowboat/apps// + manifest.json # id, title, description, source, scope[], active, lastRun, + # entry (default "dist/index.html"), agent (optional bg-task slug) + dist/ # static assets served via app://miniapp//... + index.html # the app (self-contained; bridge shim inlined) + data.json # latest agent output (read by the host, pushed to the app) +``` + +### Serving — `app://miniapp//` (option A) +- Extend `registerAppProtocol` (`apps/main/src/main.ts`) with a new host + `miniapp`: map `app://miniapp//` → `~/.rowboat/apps//dist/` + (path-traversal guarded; default `index.html`). `app://` is already registered + privileged (standard/secure/fetch/cors) so apps get a **real origin** — + remote CDNs/images and `fetch` work, unlike the opaque `srcdoc` origin. +- The iframe loads via `src="app://miniapp//index.html"` (not `srcDoc`). + Sandbox becomes `allow-scripts allow-same-origin allow-popups allow-forms + allow-modals allow-downloads` — same-origin is its OWN `app://miniapp` origin, + still isolated from the renderer (different host). + +### Data path +- Built-in/static `data` is seeded to `data.json`. A future background agent + (reuse bg-tasks engine, linked via manifest `agent`) overwrites it on schedule. +- App keeps using `rowboat.onData`; the **host** now sources that data by reading + `~/.rowboat/apps//data.json` (via IPC) and posting it on `ready`. Composio + still flows through the bridge RPC. (GitHub app needs no `data.json` — it pulls + live via Composio.) + +### Steps +1. **Shared schema** — `packages/shared/src/mini-app.ts`: `MiniAppManifest` zod + + type. Export it. New IPC channels in `shared/src/ipc.ts`: + - `mini-apps:seed` (req: `{apps:[{manifest, html, data?}]}`) — idempotent. + - `mini-apps:list` (res: `{manifests: MiniAppManifest[]}`). + - `mini-apps:get-data` (req `{id}`, res `{data: unknown|null}`). +2. **Main** — `apps/main/src/mini-apps-handler.ts`: `seedApps` (write + manifest/dist/data to `~/.rowboat/apps//` only if absent), `listApps` + (read manifests), `getAppData` (read data.json). Register handlers in + `ipc.ts`. Extend `registerAppProtocol` for the `miniapp` host. +3. **Renderer** — keep `MiniApp` defs + `buildMiniAppHtml`; `registry.ts` adds + `toSeed(app)` (→ `{manifest, html, data}`). `MiniAppsView`: on mount → `seed` + → `list` → render cards from manifests. `MiniAppFrame`: take the manifest, + load `src=app://miniapp//`, on `ready` fetch `mini-apps:get-data` + and post it; RPC scope from `manifest.scope`. +4. **Verify**: GitHub app end-to-end from disk (`~/.rowboat/apps/github-radar/`), + then the others; confirm connect + live PRs still work. + +### Out of scope here (next: copilot builder, tomorrow) +Copilot writing a new app folder + an associated background agent; the agent +browsing via embedded browser for social feeds; copilot verifying Composio wiring +by actually calling tools before finalizing. + +## 2c. Remaining work (after on-disk move) + +Done so far: surface + runtime (Phase 1), real scoped Composio bridge (Phase 2), +apps on disk served via `app://miniapp` (Phase 2.5). + +**Next — Copilot builder (demo target).** +- Copilot creates an app folder in `~/.rowboat/apps//` via the `mini-apps:seed` + install primitive (writes `manifest.json` + `dist/index.html`). +- Copilot must **verify wiring by actually calling Composio tools** and inspecting + the returned data before finalizing — never speculate the shape. +- Give generated apps the bridge contract — prefer **serving a canonical shim** + from the protocol (e.g. `app://miniapp/__bridge__.js`) over inlining. + +**Background-agent data pipeline (deterministic).** +- Reuse the existing bg-tasks engine; link via `manifest.agent`. +- The agent does NOT write files. It **returns structured data validated against + the app's data schema**; the bg-task **runner (code) atomically writes the + app's `data.json`** (temp→rename, keep last-good on failure). Path / atomicity + / validation / fallback all live in code — only the *content* is LLM-driven. +- Social feeds (Twitter/LinkedIn/Reddit): the agent browses via the embedded + browser, curates, returns data → runner writes `data.json`. + +**Later (roadmap).** +- Living apps + breakage recovery (edit by chat; surface errors → fix-with-copilot). +- Scale/polish: context-overload windowing; app notifications; design-consistency + / component templates (team-deferred). +- V2: drag an app into the main workspace. + +## 2d. Mini App builder skill — spec + +A Copilot skill (`build-mini-app`, in `packages/core/src/application/assistant/ +skills/`) that turns a chat request into an installed app under `~/.rowboat/ +apps//`. Copilot orchestrates; the actual code-writing is delegated by the +chat's active engine (the chip), but the on-disk artifact is identical either way. + +**Trigger + intent gate** (like live-note/background-task signal tiers): +- **Strong (build directly):** "make/build/create an app · mini app · dashboard + for …", "turn this into an app". +- **Medium (CONFIRM FIRST):** requests that could be a one-off answer or a + recurring app — e.g. "show me my open PRs", "track competitor launches". Ask: + "Want this as a Mini App you can reopen, or just a one-time answer?" Build only + on yes. (Building installs a folder + maybe a bg agent + an OAuth prompt — too + heavy to trigger on a casual question.) +- **Anti (do NOT build):** clearly one-off lookups/questions → just answer. + +**Flow:** +1. **Scope the app** — derive `id` (slug), `title`, `description`, `source`, the + Composio `scope[]`, and whether it's **agent-backed** (scheduled data) or + **live** (calls Composio on use). +2. **Verify wiring BEFORE building** (mandatory, engine-agnostic) — ensure the + toolkits are connected (prompt OAuth if not), then actually call the needed + tools (`composio-search-tools` → `composio-execute-tool`), inspect the REAL + returned data, and derive the **data schema from actual responses** — never + guess the shape. +3. **Pick the writer (branch):** + - **Code Mode active** → create the folder + a manifest skeleton, then + `code_agent_run` with `cwd = ~/.rowboat/apps//` to author + `dist/index.html` against the verified schema + bridge contract; it can + iterate/test on-device. + - **No Code Mode** → Copilot writes `dist/index.html` itself (from the app + template + bridge shim) and installs via `mini-apps:seed`. +4. **Bridge contract** — generated app references the canonical shim + (`app://miniapp/__bridge__.js`) and uses `window.rowboat` + (`getData/onData`, `isConnected/connect`, `searchTools/callAction`). +5. **Data pipeline (if agent-backed)** — create a background task (existing + bg-tasks engine), set `manifest.agent` to its slug. The agent **returns + schema-validated data**; the **runner writes `data.json` deterministically** + (temp→rename, last-good on failure). App reads it via `onData`. +6. **Finalize** — write `manifest.json` (incl. `scope`, `agent?`), ensure + `dist/index.html`, install → app appears in the gallery (`mini-apps:list`). + Confirm end-to-end (open it; data loads). + +**Infra this needs (repo side):** +- Serve the canonical bridge shim from the protocol: `app://miniapp/__bridge__.js` + (move the shim out of per-app inlining into served infra). +- bg-tasks runner: when a task is app-linked, validate its structured output + against the app schema and write that app's `data.json` (deterministic write + mode) instead of `index.md`. +- A `build-mini-app` skill registered in the skills catalog. + +## 3. Phase 1 — detailed implementation + +UI-first. Everything hand-coded; no `~/.rowboat` storage, no IPC, no agent yet. +We *do* mirror the eventual shapes so later phases slot in. + +### 3.1 New files (renderer) + +| File | Purpose | +|------|---------| +| `apps/renderer/src/mini-apps/types.ts` | `MiniApp` type (id, name, description, icon, accent, scope, html, data). | +| `apps/renderer/src/mini-apps/registry.ts` | Hardcoded list of `MiniApp`s for Phase 1. | +| `apps/renderer/src/mini-apps/apps/twitter-client.ts` | The sample app: self-contained HTML (React+Tailwind+Babel CDN) + static `data`. | +| `apps/renderer/src/components/mini-apps-view.tsx` | Card grid; internal `selectedAppId` state; renders grid or open app. | +| `apps/renderer/src/components/mini-app-frame.tsx` | Sandboxed iframe (`srcdoc`) + `window.rowboat` postMessage bridge (data wired; actions stubbed → toast/log). | + +The bridge message protocol (host ↔ iframe), defined once and shared: +- iframe → host: `{ type: 'rowboat:mini-app:ready' }`, + `{ type: 'rowboat:mini-app:action', id, scope, action, args }`, + `{ type: 'rowboat:mini-app:setState', patch }` +- host → iframe: `{ type: 'rowboat:mini-app:data', data }`, + `{ type: 'rowboat:mini-app:state', state }`, + `{ type: 'rowboat:mini-app:action-result', id, ok, result?, error? }` + +### 3.2 Wiring into `App.tsx` (mirror the `bg-tasks` view exactly) + +Add a first-class `apps` view. Edit sites (all mirror an existing view): + +1. **Tab path const** (~L198): add `const APPS_TAB_PATH = '__rowboat_mini_apps__'`. +2. **Tab predicate** (~L374): `const isAppsTabPath = (path) => path === APPS_TAB_PATH`. +3. **ViewState union** (~L636): add `| { type: 'apps' }`. +4. **`parseDeepLink`** (~L713): add `case 'apps': return { type: 'apps' }`. +5. **State flag** (~L825): `const [isAppsOpen, setIsAppsOpen] = useState(false)`. +6. **Tab title** (~L1253): `if (isAppsTabPath(tab.path)) return 'Mini Apps'`. +7. **Tab activation → flag** (~L3269, ~L3443): set `isAppsOpen` from tab path. +8. **Closing-tab guard** (~L3376): add `&& !isAppsTabPath(closingTab.path)`. +9. **`currentViewState`** (~L3770): `if (isAppsOpen) return { type: 'apps' }` + (place alongside bg-tasks; add to deps array). +10. **`ensureAppsFileTab`** (~L3853): mirror `ensureBgTasksFileTab`. +11. **`openAppsView`** (~L3953): mirror `openBgTasksView`; reset all other flags, + `setIsAppsOpen(true)`, `ensureAppsFileTab()`. +12. **`applyViewState` switch** (~L4195): add `case 'apps':` mirroring `bg-tasks`. +13. **Add `setIsAppsOpen(false)`** to every *other* view's reset block (the + shared multi-`set...(false)` lines) so opening another view clears apps and + closing it doesn't fall back to apps. Use targeted edits per block. +14. **Derived booleans** that OR all view flags (isFullScreenChat, + rightPaneAvailable, inFileView, rightPaneContext, viewOpen, keyboard guards): + add `|| isAppsOpen` / `&& !isAppsOpen` consistently (search `isBgTasksOpen`). +15. **Tab-path mapping** (~L4668): add `: isAppsOpen ? APPS_TAB_PATH`. +16. **Render branch** (~L5894): add `) : isAppsOpen ? (`. + +### 3.3 Sidebar entry (`sidebar-content.tsx`) + +- Add `onOpenApps?: () => void` prop (~L163) and destructure (~L412). +- Add a `SidebarMenuButton` with `isActive={activeNav === 'apps'}` and a + `LayoutGrid` (lucide) icon, label "Mini Apps", in the lower nav group near + "Background agents" (~L830). +- Thread `activeNav === 'apps'` from the parent (App.tsx passes `activeNav` based + on `isAppsOpen`, mirroring how `'agents'` is derived ~L5651). +- Wire `onOpenApps={openAppsView}` at the `SidebarContent`/home call sites + (~L5657, ~L5832). + +### 3.4 The sample app (twitter-client) + +Self-contained HTML string. React + ReactDOM + Babel-standalone + Tailwind, all +via CDN. Renders three sections — **To read / To repost / To respond** (reply +pre-drafted) — from data delivered via `rowboat.onData`. Buttons call +`rowboat.callAction('twitter', ...)`; in Phase 1 the host just toasts/logs and +returns a fake ok. Static `data` lives next to the HTML. + +> Note: CDN scripts require network + `allow-scripts` in the sandbox. The frame +> uses `sandbox="allow-scripts allow-popups allow-forms allow-modals"` — **no** +> `allow-same-origin` (keeps the app from reaching host cookies/storage; the +> bridge is the only channel). Verify CDN loads under this sandbox during testing; +> if blocked, fall back to bundling the libs locally as a renderer asset. + +### 3.5 Verify + +- `cd apps/x && npm run deps && npm run lint` compiles clean. +- `npm run dev`: "Mini Apps" appears in the sidebar; opens the card grid; clicking + the Twitter card renders the app full-screen in the pane; sections populate from + data; clicking a button shows the stubbed action result; tabs/back/forward and + switching to other views behave like every other view. + +## 4. Out of scope for Phase 1 (later phases) + +`~/.rowboat/apps//` storage + IPC; the agent backend (reuse bg-tasks +engine); real Composio execution through the bridge; per-app scope enforcement & +auth prompting; generation via Copilot/Code-Mode; persistent state store; +conversational editing & error recovery; the V2 workspace drag. diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 8fc4b754..5119b6ba 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -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, +): 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 }; + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index d5126e47..b18ed37b 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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---- 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('agentScheduleRepo'); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 4e1d42e0..c0cbb785 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a5770b74..f37e3322 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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(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(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(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(() => { @@ -5997,7 +6102,7 @@ function App() { return ( { - 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 ? ( 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 ? ( Version history )} - {!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 && ( + + +
+ {error &&
{error}
} + {!app ? ( +
Loading…
+ ) : ( +
+
+
App
+ {app.status === 'invalid' && ( +
+ Invalid manifest: {app.manifestError} +
+ )} + + + + {manifest?.description ?

{manifest.description}

: null} +
+ +
+
Capabilities
+ {manifest && manifest.capabilities.length > 0 ? ( +
+ {manifest.capabilities.map((c) => ( + {c} + ))} +
+ ) : ( +

None declared — this app can’t use tools, LLM, or the copilot.

+ )} +
+ +
+
Source
+ {app.kind === 'installed' && app.install ? ( + <> + + + {app.install.updatedAt && } + + ) : ( +

Local app — created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.

+ )} + {rollback && ( + + )} +
+ +
+
Background agents
+ {agents.length === 0 ? ( +

No bundled agents.

+ ) : agents.map((a) => ( +
+
+
{a.name}
+
+ {a.lastRunError ? `Failed: ${a.lastRunError}` : a.lastRunAt ? `Last run ${new Date(a.lastRunAt).toLocaleString()}` : 'Never run'} +
+
+ + +
+ ))} +
+ + {readme && ( +
+
README
+
{readme}
+
+ )} +
+ )} +
+ + ) +} + +function InfoRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) { + return ( +
+ {k} + {v} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/apps/app-frame.tsx b/apps/x/apps/renderer/src/components/apps/app-frame.tsx new file mode 100644 index 00000000..c406b903 --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/app-frame.tsx @@ -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 ( +
+
+ + {title} + + + +
+
+
+