mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
Merge branch 'main' into fix/linux-meeting-system-audio
This commit is contained in:
commit
5ad7ddb17b
98 changed files with 7431 additions and 2634 deletions
|
|
@ -83,7 +83,7 @@ You can set up background agents that run on events like new email or on schedul
|
|||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Built-in Browser</h3>
|
||||
Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because its isolated from your main browser, you can log in only to the accounts that want the assistant to access.
|
||||
Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because it's isolated from your main browser, you can log in only to the accounts that want the assistant to access.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="948" alt="Browser screenshot" src="assets/readme-dark/browser.png" />
|
||||
|
|
@ -110,7 +110,7 @@ Code mode lets you spin up parallel coding agents with Claude Code or Codex, and
|
|||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Apps</h3>
|
||||
You can bulild your own work surfaces inside Rowboat — they get acess to all the tools and integrations, and you can share them with other people.
|
||||
You can build your own work surfaces inside Rowboat — they get access to all the tools and integrations, and you can share them with other people.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img width="1512" height="949" alt="Apps screenshot" src="assets/readme-dark/apps.png" />
|
||||
|
|
|
|||
|
|
@ -96,6 +96,14 @@ All in `apps/renderer/src/lib/analytics.ts`:
|
|||
- `search_executed` — `{ types: string[] }`
|
||||
- `note_exported` — `{ format }`
|
||||
|
||||
### Client auto-update funnel
|
||||
|
||||
The desktop client's own updates — distinct from the in-app apps feature, which owns `app_updated`:
|
||||
|
||||
- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the "Update available" card was shown for a staged update
|
||||
- `update_restarted` — main (`apps/main/src/updater.ts`), `{ from, to? }`: the user clicked restart-to-update (`to` may be missing when the update feed doesn't report the release name)
|
||||
- `update_failed` — main (`apps/main/src/updater.ts`), `{ message }`: the auto-updater errored (includes network errors for now)
|
||||
- `client_updated` — main (`apps/main/src/ipc.ts`), `{ from, to }`: first launch on a newer version (fires once per update, whatever the restart path; downgrades restamp silently and don't fire)
|
||||
### `view_opened` — feature-importance funnel
|
||||
|
||||
One event per view the user lands on, fired centrally from the `currentViewState` effect in `apps/renderer/src/App.tsx`. `view` is one of: `chat`, `file`, `graph`, `task`, `suggested-topics`, `meetings`, `live-notes`, `email`, `workspace`, `knowledge-view`, `chat-history`, `home`, `code`, `bg-tasks`, `apps`. Keyed on the view *type*, so switching files or threads inside a view doesn't re-fire.
|
||||
|
|
@ -173,7 +181,8 @@ All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers
|
|||
|
||||
- `email_send_failed` — send returned an error or threw (`components/email-view.tsx`)
|
||||
- `meeting_summarize_failed` — post-recording notes generation threw (`App.tsx`)
|
||||
- `bg_agent_run_failed` / `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — every background-agent run settles as exactly one of these (`packages/core/src/background-tasks/runner.ts`), giving a failure *rate* across all trigger sources, not just manual clicks
|
||||
- `bg_agent_run_failed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event', error: string }` **(core)** — emitted when a background-agent run fails; `error` contains the normalized failure message
|
||||
- `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — emitted when a background-agent run succeeds; together these events give a failure *rate* across all trigger sources, not just manual clicks
|
||||
|
||||
**Misc**:
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,17 @@ if (!fs.existsSync(stagedBinary)) {
|
|||
}
|
||||
console.log('✅ node-pty staged in .package/node_modules');
|
||||
|
||||
// electron-chrome-extensions injects a preload script into browser tabs to
|
||||
// implement the chrome.* extension APIs. It resolves that file at runtime:
|
||||
// via require.resolve when node_modules is present (dev), falling back to a
|
||||
// file next to the running bundle (packaged app, where node_modules is
|
||||
// gone). Stage it next to main.cjs for the packaged case.
|
||||
const crxPreloadSrc = fs.realpathSync(
|
||||
path.join(here, 'node_modules', 'electron-chrome-extensions', 'dist', 'chrome-extension-api.preload.js'),
|
||||
);
|
||||
fs.copyFileSync(crxPreloadSrc, path.join(here, '.package', 'dist', 'chrome-extension-api.preload.js'));
|
||||
console.log('✅ electron-chrome-extensions preload staged');
|
||||
|
||||
// Compile the mic-monitor helper (ambient meeting detection) on macOS.
|
||||
// Best-effort: without swiftc — or on other platforms — the app still works,
|
||||
// ad-hoc meeting detection just stays off (main checks the binary exists).
|
||||
|
|
|
|||
|
|
@ -250,6 +250,16 @@ module.exports = {
|
|||
name: `Rowboat-win32-${arch}`,
|
||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
||||
// The animation is Squirrel's ONLY install UI — without this
|
||||
// users stare at Squirrel's unbranded default mid-install.
|
||||
loadingGif: path.join(__dirname, 'icons/install-loading.gif'),
|
||||
// Add/Remove Programs icon. Must be a remote URL (Squirrel
|
||||
// limitation); defaults to the Atom feather otherwise.
|
||||
iconUrl: 'https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/x/apps/main/icons/icon.ico',
|
||||
// Skip the machine-wide MSI deployment stub — it lands on the
|
||||
// GitHub release page next to setup.exe and users grab the
|
||||
// wrong one (it neither launches the app nor auto-updates).
|
||||
noMsi: true,
|
||||
})
|
||||
},
|
||||
{
|
||||
|
|
|
|||
44
apps/x/apps/main/icons/gen-install-loading.sh
Normal file
44
apps/x/apps/main/icons/gen-install-loading.sh
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-off dev tool, run manually (requires ImageMagick) — NOT part of any
|
||||
# build step. Its output, install-loading.gif, is committed and wired into
|
||||
# forge.config.cjs as Squirrel.Windows' `loadingGif` (the installer's only
|
||||
# UI). Re-run only if the icon or branding changes.
|
||||
#
|
||||
# Generates the Squirrel install animation: icon + title + operation label.
|
||||
# Timeline approximates a typical 20-25s install, then holds at "Almost done"
|
||||
# for the slow-machine tail. Frame every 0.5s. No progress bar — Squirrel gives
|
||||
# no real progress signal, so a bar would just be a fake timeline.
|
||||
set -euo pipefail
|
||||
|
||||
ICON=$(dirname "$0")/icon.png
|
||||
# DejaVu Sans by name on systems that have it installed; set FONT to a
|
||||
# DejaVuSans.ttf path on systems that don't (e.g. Windows/macOS).
|
||||
FONT="${FONT:-DejaVu-Sans}"
|
||||
OUT_DIR=$(mktemp -d)
|
||||
mkdir -p "$OUT_DIR"
|
||||
rm -f "$OUT_DIR"/frame-*.png
|
||||
|
||||
FRAMES=110 # 55s total at 0.5s/frame
|
||||
|
||||
for i in $(seq 0 $((FRAMES - 1))); do
|
||||
label=$(awk -v i="$i" 'BEGIN {
|
||||
t = i * 0.5
|
||||
if (t < 14) print "Installing"; else if (t < 21) print "Creating shortcuts"; else print "Almost done"
|
||||
}')
|
||||
|
||||
# Animated trailing dots (ellipsis), cycling every 2s
|
||||
ndots=$((i % 4))
|
||||
dots=""
|
||||
for _ in $(seq 1 $ndots); do dots=". $dots"; done
|
||||
|
||||
magick -size 480x320 xc:'#252525' \
|
||||
\( "$ICON" -resize 84x84 \) -gravity center -geometry +0-72 -composite \
|
||||
-gravity center -font "$FONT" -pointsize 21 -fill '#e8e8e8' -annotate +0+8 'Installing Rowboat' \
|
||||
-gravity center -font "$FONT" -pointsize 14 -fill '#9a9a9a' -annotate +0+78 "$label" \
|
||||
-gravity center -font "$FONT" -pointsize 14 -fill '#6f6f6f' -annotate +0+100 "$dots" \
|
||||
"$OUT_DIR/frame-$(printf '%03d' "$i").png"
|
||||
done
|
||||
|
||||
magick -delay 50 -loop 0 "$OUT_DIR"/frame-*.png -layers Optimize "$(dirname "$0")/install-loading.gif"
|
||||
echo "frames: $FRAMES"
|
||||
ls -la "$(dirname "$0")/install-loading.gif"
|
||||
BIN
apps/x/apps/main/icons/install-loading.gif
Normal file
BIN
apps/x/apps/main/icons/install-loading.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
|
|
@ -19,13 +19,13 @@
|
|||
"@x/shared": "workspace:*",
|
||||
"agent-slack": "0.9.3",
|
||||
"chokidar": "^4.0.3",
|
||||
"electron-chrome-extensions": "^4.9.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"update-electron-app": "^3.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,9 +20,46 @@ export interface AuthServerResult {
|
|||
port: number;
|
||||
}
|
||||
|
||||
interface CallbackHandlingOpts {
|
||||
callbackPath: string;
|
||||
/** Invoked when the provider redirects back with an `error` param. */
|
||||
onError?: (error: string) => void;
|
||||
/**
|
||||
* Gatekeeper run BEFORE the error/callback handling. Return a message to
|
||||
* reject the request with a polite close-this-tab error page — without
|
||||
* invoking onCallback/onError. Lets a caller drop stale callbacks (e.g. the
|
||||
* browser tab of a cancelled sign-in attempt carrying an old `state`)
|
||||
* without disturbing the live flow.
|
||||
*/
|
||||
validateCallback?: (url: URL) => string | null;
|
||||
}
|
||||
|
||||
function renderErrorPage(res: import('http').ServerResponse, message: string): void {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.error { color: #d32f2f; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">Authorization Failed</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
function tryBindPort(
|
||||
port: number,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: CallbackHandlingOpts,
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
|
|
@ -34,29 +71,24 @@ function tryBindPort(
|
|||
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
||||
if (url.pathname === opts.callbackPath) {
|
||||
// Gatekeeper first: stale/foreign requests must not reach onError or
|
||||
// onCallback (a stale tab's redirect must never settle a live flow).
|
||||
const rejection = opts.validateCallback?.(url) ?? null;
|
||||
if (rejection) {
|
||||
renderErrorPage(res, rejection);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.error { color: #d32f2f; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">Authorization Failed</h1>
|
||||
<p>Error: ${escapeHtml(error)}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
// Surface the provider error (e.g. access_denied when the user
|
||||
// cancels consent) so the caller can settle its flow instead of
|
||||
// waiting for the timeout. Callers that don't opt in keep the old
|
||||
// behaviour: the error page renders and the flow times out.
|
||||
opts.onError?.(error);
|
||||
renderErrorPage(res, `Error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -117,18 +149,29 @@ function tryBindPort(
|
|||
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
|
||||
* both EADDRINUSE and EACCES (the latter is common on Windows when
|
||||
* Hyper-V/WSL2 reserve the port).
|
||||
*
|
||||
* `callbackPath` overrides the served path for providers whose registered
|
||||
* redirect URI differs (ChatGPT/Codex uses /auth/callback). `onError` is
|
||||
* invoked when the provider redirects back with an `error` param (e.g.
|
||||
* access_denied), letting the caller settle instead of waiting for timeout.
|
||||
* `validateCallback` runs before both — see CallbackHandlingOpts.
|
||||
*/
|
||||
export async function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: { fallback?: boolean } = {},
|
||||
opts: { fallback?: boolean } & Partial<CallbackHandlingOpts> = {},
|
||||
): Promise<AuthServerResult> {
|
||||
const fallback = opts.fallback === true;
|
||||
const handlingOpts: CallbackHandlingOpts = {
|
||||
callbackPath: opts.callbackPath ?? OAUTH_CALLBACK_PATH,
|
||||
onError: opts.onError,
|
||||
validateCallback: opts.validateCallback,
|
||||
};
|
||||
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
|
||||
|
||||
for (let p = port; p <= limit; p++) {
|
||||
try {
|
||||
return await tryBindPort(p, onCallback);
|
||||
return await tryBindPort(p, onCallback, handlingOpts);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
|
||||
|
|
|
|||
129
apps/x/apps/main/src/browser/extensions.ts
Normal file
129
apps/x/apps/main/src/browser/extensions.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { session, type BrowserWindow, type Session, type WebContents } from 'electron';
|
||||
import { ElectronChromeExtensions } from 'electron-chrome-extensions';
|
||||
import { WorkDir } from '@x/core/dist/config/config.js';
|
||||
import { browserViewManager } from './view.js';
|
||||
|
||||
/**
|
||||
* Chrome extension support for the embedded browser, via
|
||||
* electron-chrome-extensions (GPL-3.0 / Patron dual-licensed — see the
|
||||
* library's LICENSE.md before shipping this in a release build).
|
||||
*
|
||||
* Extensions are loaded unpacked from ~/.rowboat/extensions/<name>/ — either
|
||||
* a directory containing manifest.json directly, or (Chrome Web Store
|
||||
* unpacked layout) a directory whose single versioned subdirectory contains
|
||||
* it. There is no install UI; drop a folder there and restart the app.
|
||||
*
|
||||
* Known limits:
|
||||
* - The browser session's client-hint spoofing uses Electron's webRequest
|
||||
* API, which prevents extensions' chrome.webRequest listeners from firing
|
||||
* (Electron allows one consumer per session). Blockers that rely on
|
||||
* webRequest (uBlock MV2) won't block; content-script-based extensions
|
||||
* (Dark Reader, password managers) work.
|
||||
* - declarativeNetRequest is not implemented by Electron.
|
||||
*/
|
||||
|
||||
const EXTENSIONS_DIR = path.join(WorkDir, 'extensions');
|
||||
|
||||
let extensions: ElectronChromeExtensions | null = null;
|
||||
|
||||
/**
|
||||
* Called once at startup (before any browser use). Binds the extension
|
||||
* system to the browser session when the BrowserViewManager creates it, and
|
||||
* mirrors the manager's tab lifecycle into chrome.tabs.
|
||||
*/
|
||||
export function setupBrowserExtensions(): void {
|
||||
browserViewManager.on('session-created', (browserSession: Session) => {
|
||||
initExtensions(browserSession);
|
||||
});
|
||||
browserViewManager.on('tab-created', (wc: WebContents, win: BrowserWindow) => {
|
||||
try {
|
||||
extensions?.addTab(wc, win);
|
||||
} catch (error) {
|
||||
console.error('[Extensions] addTab failed:', error);
|
||||
}
|
||||
});
|
||||
browserViewManager.on('tab-selected', (wc: WebContents) => {
|
||||
try {
|
||||
extensions?.selectTab(wc);
|
||||
} catch (error) {
|
||||
console.error('[Extensions] selectTab failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Serves crx:// (extension action icons) to the app renderer, which hosts
|
||||
// the <browser-action-list> element on the default session; the element's
|
||||
// partition attribute routes state queries to the browser session.
|
||||
ElectronChromeExtensions.handleCRXProtocol(session.defaultSession);
|
||||
}
|
||||
|
||||
function initExtensions(browserSession: Session): void {
|
||||
if (extensions) return;
|
||||
|
||||
extensions = new ElectronChromeExtensions({
|
||||
license: 'GPL-3.0',
|
||||
session: browserSession,
|
||||
async createTab(details) {
|
||||
const result = await browserViewManager.newTab(details.url);
|
||||
if (!result.ok || !result.tabId) {
|
||||
throw new Error(result.error ?? 'Failed to create tab');
|
||||
}
|
||||
const wc = browserViewManager.getTabWebContents(result.tabId);
|
||||
const win = browserViewManager.getWindow();
|
||||
if (!wc || !win) throw new Error('Browser window is not available');
|
||||
return [wc, win];
|
||||
},
|
||||
selectTab(wc) {
|
||||
const tabId = browserViewManager.getTabIdForWebContents(wc);
|
||||
if (tabId) browserViewManager.switchTab(tabId);
|
||||
},
|
||||
removeTab(wc) {
|
||||
const tabId = browserViewManager.getTabIdForWebContents(wc);
|
||||
if (tabId) browserViewManager.closeTab(tabId);
|
||||
},
|
||||
});
|
||||
|
||||
void loadUnpackedExtensions(browserSession);
|
||||
}
|
||||
|
||||
async function resolveExtensionRoot(dir: string): Promise<string | null> {
|
||||
const hasManifest = async (candidate: string) =>
|
||||
fs.access(path.join(candidate, 'manifest.json')).then(() => true, () => false);
|
||||
|
||||
if (await hasManifest(dir)) return dir;
|
||||
|
||||
// Chrome Web Store unpacked layout: <id>/<version>/manifest.json. Pick the
|
||||
// lexically-latest version directory.
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
const versionDirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const name of versionDirs) {
|
||||
const candidate = path.join(dir, name);
|
||||
if (await hasManifest(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadUnpackedExtensions(browserSession: Session): Promise<void> {
|
||||
await fs.mkdir(EXTENSIONS_DIR, { recursive: true });
|
||||
const entries = await fs.readdir(EXTENSIONS_DIR, { withFileTypes: true }).catch(() => []);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const root = await resolveExtensionRoot(path.join(EXTENSIONS_DIR, entry.name));
|
||||
if (!root) {
|
||||
console.warn(`[Extensions] No manifest.json under ${path.join(EXTENSIONS_DIR, entry.name)} — skipped`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const extension = await browserSession.extensions.loadExtension(root);
|
||||
console.log(`[Extensions] Loaded ${extension.name}@${extension.version} (${root})`);
|
||||
} catch (error) {
|
||||
console.error(`[Extensions] Failed to load ${root}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import { browserViewManager, type BrowserState, type HttpAuthRequest } from './view.js';
|
||||
import { browserViewManager, type BrowserState, type DisplayMediaRequest, type HttpAuthRequest } from './view.js';
|
||||
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ type BrowserHandlers = {
|
|||
'browser:reload': InvokeHandler<'browser:reload'>;
|
||||
'browser:getState': InvokeHandler<'browser:getState'>;
|
||||
'browser:httpAuthResponse': InvokeHandler<'browser:httpAuthResponse'>;
|
||||
'browser:displayMediaResponse': InvokeHandler<'browser:displayMediaResponse'>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -66,6 +67,9 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
'browser:httpAuthResponse': async (_event, args) => {
|
||||
return browserViewManager.respondToHttpAuth(args);
|
||||
},
|
||||
'browser:displayMediaResponse': async (_event, args) => {
|
||||
return browserViewManager.respondToDisplayMedia(args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -96,4 +100,12 @@ export function setupBrowserEventForwarding(): void {
|
|||
browserViewManager.on('http-auth-resolved', (requestId: string) => {
|
||||
broadcast('browser:httpAuthResolved', { requestId });
|
||||
});
|
||||
|
||||
browserViewManager.on('display-media-request', (request: DisplayMediaRequest) => {
|
||||
broadcast('browser:displayMediaRequest', request);
|
||||
});
|
||||
|
||||
browserViewManager.on('display-media-resolved', (requestId: string) => {
|
||||
broadcast('browser:displayMediaResolved', { requestId });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserWindow, WebContentsView, session, shell, type Session, type WebContents } from 'electron';
|
||||
import { BrowserWindow, WebContentsView, desktopCapturer, session, shell, type Session, type WebContents } from 'electron';
|
||||
import type {
|
||||
BrowserPageElement,
|
||||
BrowserPageSnapshot,
|
||||
BrowserState,
|
||||
BrowserTabState,
|
||||
DisplayMediaRequest,
|
||||
HttpAuthRequest,
|
||||
} from '@x/shared/dist/browser-control.js';
|
||||
import { normalizeNavigationTarget } from './navigation.js';
|
||||
|
|
@ -21,7 +22,7 @@ import {
|
|||
type RawBrowserPageSnapshot,
|
||||
} from './page-scripts.js';
|
||||
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState, HttpAuthRequest };
|
||||
export type { BrowserPageSnapshot, BrowserState, BrowserTabState, DisplayMediaRequest, HttpAuthRequest };
|
||||
|
||||
/**
|
||||
* Embedded browser pane implementation.
|
||||
|
|
@ -64,6 +65,8 @@ const SPOOF_UA = buildChromeUserAgent();
|
|||
const HOME_URL = 'https://www.google.com';
|
||||
const NAVIGATION_TIMEOUT_MS = 10000;
|
||||
const HTTP_AUTH_TIMEOUT_MS = 120000;
|
||||
const DISPLAY_MEDIA_TIMEOUT_MS = 120000;
|
||||
const DISPLAY_MEDIA_THUMBNAIL_SIZE = { width: 320, height: 180 };
|
||||
const POST_ACTION_IDLE_MS = 400;
|
||||
const POST_ACTION_MAX_ELEMENTS = 25;
|
||||
const POST_ACTION_MAX_TEXT_LENGTH = 4000;
|
||||
|
|
@ -96,6 +99,14 @@ type PendingHttpAuth = {
|
|||
webContents: WebContents;
|
||||
};
|
||||
|
||||
type PendingDisplayMedia = {
|
||||
callback: (streams: Electron.Streams) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
// The picker answers with a source id; the native callback needs the full
|
||||
// DesktopCapturerSource, so keep the gathered sources until resolution.
|
||||
sources: Map<string, Electron.DesktopCapturerSource>;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
activeTabId: null,
|
||||
tabs: [],
|
||||
|
|
@ -138,6 +149,7 @@ export class BrowserViewManager extends EventEmitter {
|
|||
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||
private snapshotCache = new Map<string, CachedSnapshot>();
|
||||
private pendingHttpAuth = new Map<string, PendingHttpAuth>();
|
||||
private pendingDisplayMedia = new Map<string, PendingDisplayMedia>();
|
||||
// Child windows created by page window.open() (OAuth/SSO popups). Tracked so
|
||||
// they can be closed when the host window goes away — otherwise an orphaned
|
||||
// popup keeps BrowserWindow.getAllWindows() non-empty and, on macOS, blocks
|
||||
|
|
@ -188,6 +200,9 @@ export class BrowserViewManager extends EventEmitter {
|
|||
for (const requestId of [...this.pendingHttpAuth.keys()]) {
|
||||
this.finishHttpAuth(requestId);
|
||||
}
|
||||
for (const requestId of [...this.pendingDisplayMedia.keys()]) {
|
||||
this.finishDisplayMedia(requestId);
|
||||
}
|
||||
// Close any OAuth/SSO popups so they don't outlive the app window.
|
||||
for (const popup of popups) {
|
||||
if (!popup.isDestroyed()) popup.close();
|
||||
|
|
@ -244,10 +259,108 @@ export class BrowserViewManager extends EventEmitter {
|
|||
callback({ requestHeaders });
|
||||
});
|
||||
|
||||
// getDisplayMedia() from pages (e.g. Google Meet "Present now"). When the
|
||||
// browser pane is on screen, forward a source picker to the renderer and
|
||||
// resolve with the user's choice. Registered here (not in main.ts's
|
||||
// configureSessionPermissions) so the picker plumbing lives with the rest
|
||||
// of the pane's request/response wiring; the app's own session keeps its
|
||||
// auto-approve loopback handler for meeting transcription.
|
||||
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
this.handleDisplayMediaRequest(callback).catch(() => callback({}));
|
||||
});
|
||||
|
||||
this.browserSession = browserSession;
|
||||
// Synchronous on purpose: the extension system (browser/extensions.ts)
|
||||
// must bind to the session — registering its tab-API preload — before the
|
||||
// first tab's WebContentsView is constructed right after this returns.
|
||||
this.emit('session-created', browserSession);
|
||||
return browserSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page's getDisplayMedia() request. With the pane visible, gather
|
||||
* shareable sources and ask the renderer to show a picker (denied after a
|
||||
* timeout if unanswered). With the pane hidden, deny outright: nobody can
|
||||
* answer a picker, and auto-granting would hand a live screen capture to an
|
||||
* arbitrary page without consent. `visible` is also false whenever the
|
||||
* renderer hides the view behind a blocking overlay — including this
|
||||
* picker's own dialog — so a page re-requesting mid-picker lands here and
|
||||
* must be denied, not silently granted.
|
||||
*/
|
||||
private async handleDisplayMediaRequest(callback: (streams: Electron.Streams) => void): Promise<void> {
|
||||
if (!this.visible || !this.window) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: DISPLAY_MEDIA_THUMBNAIL_SIZE,
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
if (sources.length === 0) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
this.finishDisplayMedia(requestId);
|
||||
}, DISPLAY_MEDIA_TIMEOUT_MS);
|
||||
this.pendingDisplayMedia.set(requestId, {
|
||||
callback,
|
||||
timer,
|
||||
sources: new Map(sources.map((source) => [source.id, source])),
|
||||
});
|
||||
|
||||
const request: DisplayMediaRequest = {
|
||||
requestId,
|
||||
sources: sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
kind: source.id.startsWith('screen:') ? 'screen' as const : 'window' as const,
|
||||
thumbnailDataUrl: source.thumbnail.isEmpty() ? '' : source.thumbnail.toDataURL(),
|
||||
// appIcon is typed non-null but is absent for screen sources.
|
||||
appIconDataUrl: source.appIcon && !source.appIcon.isEmpty() ? source.appIcon.toDataURL() : null,
|
||||
})),
|
||||
};
|
||||
this.emit('display-media-request', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending display-media request. `sourceId === undefined` (or an
|
||||
* id no longer in the gathered set) denies it. Always notifies the renderer
|
||||
* so a picker it may still be showing (e.g. after a timeout) is pruned.
|
||||
*/
|
||||
private finishDisplayMedia(requestId: string, sourceId?: string, audio?: boolean): boolean {
|
||||
const pending = this.pendingDisplayMedia.get(requestId);
|
||||
if (!pending) return false;
|
||||
this.pendingDisplayMedia.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
const source = sourceId != null ? pending.sources.get(sourceId) : undefined;
|
||||
try {
|
||||
if (!source) {
|
||||
pending.callback({});
|
||||
} else if (audio) {
|
||||
pending.callback({ video: source, audio: 'loopback' });
|
||||
} else {
|
||||
pending.callback({ video: source });
|
||||
}
|
||||
} catch {
|
||||
// The requesting webContents may already be destroyed.
|
||||
}
|
||||
this.emit('display-media-resolved', requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
respondToDisplayMedia(input: {
|
||||
requestId: string;
|
||||
sourceId?: string;
|
||||
audio?: boolean;
|
||||
}): { ok: boolean } {
|
||||
return { ok: this.finishDisplayMedia(input.requestId, input.sourceId, input.audio) };
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
this.emit('state-updated', this.snapshotState());
|
||||
}
|
||||
|
|
@ -560,6 +673,10 @@ export class BrowserViewManager extends EventEmitter {
|
|||
this.invalidateSnapshot(tabId);
|
||||
this.syncAttachedView();
|
||||
this.emitState();
|
||||
// Register with the extension system (chrome.tabs) before the initial
|
||||
// load below so extensions observe the tab's first navigation.
|
||||
this.emit('tab-created', tab.view.webContents, this.window);
|
||||
this.emit('tab-selected', tab.view.webContents);
|
||||
|
||||
const targetUrl =
|
||||
initialUrl === 'about:blank'
|
||||
|
|
@ -733,11 +850,13 @@ export class BrowserViewManager extends EventEmitter {
|
|||
}
|
||||
|
||||
switchTab(tabId: string): { ok: boolean } {
|
||||
if (!this.tabs.has(tabId)) return { ok: false };
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return { ok: false };
|
||||
if (this.activeTabId === tabId) return { ok: true };
|
||||
this.activeTabId = tabId;
|
||||
this.syncAttachedView();
|
||||
this.emitState();
|
||||
this.emit('tab-selected', tab.view.webContents);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
|
@ -1048,6 +1167,24 @@ export class BrowserViewManager extends EventEmitter {
|
|||
return this.snapshotState();
|
||||
}
|
||||
|
||||
// Accessors for the extension system's chrome.tabs bridge
|
||||
// (browser/extensions.ts), which speaks in WebContents while the manager
|
||||
// speaks in tab ids.
|
||||
getWindow(): BrowserWindow | null {
|
||||
return this.window;
|
||||
}
|
||||
|
||||
getTabWebContents(tabId: string): WebContents | null {
|
||||
return this.getTab(tabId)?.view.webContents ?? null;
|
||||
}
|
||||
|
||||
getTabIdForWebContents(wc: WebContents): string | null {
|
||||
for (const tab of this.tabs.values()) {
|
||||
if (tab.view.webContents === wc) return tab.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private snapshotState(): BrowserState {
|
||||
if (this.tabOrder.length === 0) return { ...EMPTY_STATE };
|
||||
return {
|
||||
|
|
|
|||
247
apps/x/apps/main/src/chatgpt-signin.ts
Normal file
247
apps/x/apps/main/src/chatgpt-signin.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { shell } from 'electron';
|
||||
import type { Server } from 'http';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||
import {
|
||||
CHATGPT_AUTHORIZE_URL,
|
||||
CHATGPT_CALLBACK_PATH,
|
||||
CHATGPT_CALLBACK_PORT,
|
||||
CHATGPT_CLIENT_ID,
|
||||
CHATGPT_EXTRA_AUTHORIZE_PARAMS,
|
||||
CHATGPT_REDIRECT_URI,
|
||||
CHATGPT_SCOPES,
|
||||
} from '@x/core/dist/auth/chatgpt-constants.js';
|
||||
|
||||
// Interactive "Sign in with ChatGPT" flow (OAuth 2.0 + PKCE, Codex CLI client
|
||||
// — see chatgpt-constants.ts). Orchestration only: PKCE/state generation and
|
||||
// all token-endpoint traffic + storage live in core; this module owns the
|
||||
// system browser, the loopback callback server on 127.0.0.1:1455, and flow
|
||||
// lifecycle. The port is FIXED — the redirect URI is pre-registered at OpenAI
|
||||
// for the Codex client id, so there is no scan-to-next-port fallback.
|
||||
|
||||
export type ChatGPTSignInResult = {
|
||||
signedIn: boolean;
|
||||
email?: string;
|
||||
accountId?: string;
|
||||
/** True when the attempt was cancelled (Cancel button or superseded). */
|
||||
cancelled?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Generous, mirrors the Google flow's abandoned-flow cleanup ceiling. */
|
||||
const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type ActiveAttempt = {
|
||||
promise: Promise<ChatGPTSignInResult>;
|
||||
/**
|
||||
* Settle the attempt with a cancelled outcome. Resolves once the loopback
|
||||
* server has fully closed (listener + keep-alive connections), so a
|
||||
* follow-up attempt can rebind 1455 immediately.
|
||||
*/
|
||||
cancel: (reason: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let activeAttempt: ActiveAttempt | null = null;
|
||||
|
||||
/**
|
||||
* Start a sign-in attempt. If one is already pending it is stale by
|
||||
* definition — the user is clicking Sign In again precisely because no
|
||||
* browser flow is visibly in progress (e.g. they closed the tab and hit
|
||||
* Cancel) — so cancel it and start FRESH (new PKCE verifier/state, new
|
||||
* loopback server, new browser window). Awaiting the cancel preserves the
|
||||
* one-server-at-a-time invariant: 1455 is fully released before rebinding.
|
||||
*/
|
||||
export async function signInWithChatGPT(): Promise<ChatGPTSignInResult> {
|
||||
if (activeAttempt) {
|
||||
const stale = activeAttempt;
|
||||
activeAttempt = null;
|
||||
console.log('[ChatGPTAuth] Cancelling stale sign-in attempt before starting a new one');
|
||||
await stale.cancel('Superseded by a new sign-in attempt.');
|
||||
}
|
||||
|
||||
const attempt = startAttempt();
|
||||
activeAttempt = attempt;
|
||||
void attempt.promise.finally(() => {
|
||||
if (activeAttempt === attempt) activeAttempt = null;
|
||||
});
|
||||
return attempt.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the pending attempt (renderer Cancel button): stops the loopback
|
||||
* server, clears pending state, settles the in-flight signIn promise with a
|
||||
* cancelled outcome. No-op when nothing is pending. Never touches stored
|
||||
* tokens — after cancel, chatgpt:getStatus reports signed-out (unless an
|
||||
* earlier sign-in already completed).
|
||||
*/
|
||||
export async function cancelChatGPTSignIn(): Promise<void> {
|
||||
const attempt = activeAttempt;
|
||||
if (!attempt) return;
|
||||
activeAttempt = null;
|
||||
await attempt.cancel('Sign-in cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* One sign-in attempt. The returned promise always RESOLVES (never rejects),
|
||||
* and every exit path — success, denial, timeout, port busy, exchange
|
||||
* failure, cancellation — tears down the loopback server and the timeout
|
||||
* exactly once via the settle-once `finish`.
|
||||
*/
|
||||
function startAttempt(): ActiveAttempt {
|
||||
let settle!: (result: ChatGPTSignInResult) => void;
|
||||
const promise = new Promise<ChatGPTSignInResult>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let server: Server | null = null;
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
let serverClosed: Promise<void> | null = null;
|
||||
|
||||
// Close the listening socket AND any keep-alive connections (the browser
|
||||
// holds one open after the callback response) so 1455 frees immediately.
|
||||
const closeServer = (): Promise<void> => {
|
||||
if (serverClosed) return serverClosed;
|
||||
const s = server;
|
||||
server = null;
|
||||
serverClosed = !s
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
s.closeAllConnections();
|
||||
});
|
||||
return serverClosed;
|
||||
};
|
||||
|
||||
const finish = (result: ChatGPTSignInResult): Promise<void> => {
|
||||
if (settled) return serverClosed ?? Promise.resolve();
|
||||
settled = true;
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
const closed = closeServer();
|
||||
if (!result.signedIn) {
|
||||
console.log(`[ChatGPTAuth] Sign-in did not complete: ${result.error ?? 'unknown'}`);
|
||||
}
|
||||
settle(result);
|
||||
return closed;
|
||||
};
|
||||
|
||||
const cancel = (reason: string): Promise<void> =>
|
||||
finish({ signedIn: false, cancelled: true, error: reason });
|
||||
|
||||
void run();
|
||||
return { promise, cancel };
|
||||
|
||||
async function run(): Promise<void> {
|
||||
console.log('[ChatGPTAuth] Starting sign-in flow...');
|
||||
try {
|
||||
const { verifier, challenge } = await oauthClient.generatePKCE();
|
||||
const state = oauthClient.generateState();
|
||||
if (settled) return; // cancelled while generating PKCE — nothing bound yet
|
||||
|
||||
// Guard against duplicate callbacks (browser may send multiple requests).
|
||||
let callbackHandled = false;
|
||||
const onCallback = async (callbackUrl: URL) => {
|
||||
if (settled || callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
try {
|
||||
// State already verified by validateCallback below.
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
if (!code) {
|
||||
void finish({ signedIn: false, error: 'Sign-in failed: callback is missing the authorization code.' });
|
||||
return;
|
||||
}
|
||||
// Exchange + persistence live in core (never log token values).
|
||||
await exchangeChatGPTCode(code, verifier);
|
||||
const status = await getChatGPTStatus();
|
||||
console.log('[ChatGPTAuth] Sign-in complete');
|
||||
void finish({ ...status });
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Token exchange failed:', error);
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Token exchange failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Bind the loopback server FIRST so a busy port fails fast, before any
|
||||
// browser tab opens. Fixed port — createAuthServer's no-fallback error
|
||||
// message tells the user to free the port.
|
||||
let boundServer: Server;
|
||||
try {
|
||||
({ server: boundServer } = await createAuthServer(CHATGPT_CALLBACK_PORT, onCallback, {
|
||||
callbackPath: CHATGPT_CALLBACK_PATH,
|
||||
onError: (error) => {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error === 'access_denied'
|
||||
? 'Sign-in was cancelled in the browser.'
|
||||
: `Sign-in failed: ${error}`,
|
||||
});
|
||||
},
|
||||
// Stale callbacks — a tab left over from an earlier, cancelled
|
||||
// attempt carries that attempt's `state` — get a polite
|
||||
// close-this-tab page and never reach onError/onCallback, so they
|
||||
// can neither complete sign-in nor settle the live attempt.
|
||||
validateCallback: (url) => {
|
||||
if (settled) {
|
||||
return 'This sign-in attempt is no longer active. Close this tab and try again from Rowboat.';
|
||||
}
|
||||
if (url.searchParams.get('state') !== state) {
|
||||
return 'This sign-in link has expired. Close this tab and try again from Rowboat.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to start the sign-in callback server',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
// Cancelled while the bind was in flight — release the port we just
|
||||
// grabbed (finish() already ran with no server to close).
|
||||
boundServer.closeAllConnections();
|
||||
boundServer.close();
|
||||
return;
|
||||
}
|
||||
server = boundServer;
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
void finish({ signedIn: false, error: 'Sign-in timed out. Please try again.' });
|
||||
}, SIGN_IN_TIMEOUT_MS);
|
||||
|
||||
const authUrl = new URL(CHATGPT_AUTHORIZE_URL);
|
||||
authUrl.search = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CHATGPT_CLIENT_ID,
|
||||
redirect_uri: CHATGPT_REDIRECT_URI,
|
||||
scope: CHATGPT_SCOPES.join(' '),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
...CHATGPT_EXTRA_AUTHORIZE_PARAMS,
|
||||
}).toString();
|
||||
|
||||
try {
|
||||
// System browser: shares the user's existing ChatGPT session cookies.
|
||||
await shell.openExternal(authUrl.toString());
|
||||
} catch (error) {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? `Failed to open browser: ${error.message}` : 'Failed to open browser',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Sign-in flow error:', error);
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Sign-in failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,9 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
|
|||
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||
import { listCodexModels } from '@x/core/dist/models/codex.js';
|
||||
import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
||||
|
|
@ -66,6 +69,7 @@ import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack
|
|||
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
|
||||
import { loadTurnLimitsSettings, saveTurnLimitsSettings } from '@x/core/dist/config/turn_limits.js';
|
||||
import { saveAppSettings } from '@x/core/dist/config/app_settings.js';
|
||||
import { setSelfCaptureActive } from '@x/core/dist/meetings/detector.js';
|
||||
import { notifyIfEnabled } from '@x/core/dist/application/notification/notifier.js';
|
||||
|
|
@ -85,6 +89,8 @@ 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 { recordAppVersion, isVersionUpgrade } from '@x/core/dist/config/app_version.js';
|
||||
import { getUpdaterStatus, checkForUpdates, quitAndInstallUpdate } from './updater.js';
|
||||
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
|
||||
import * as appsStars from '@x/core/dist/apps/stars.js';
|
||||
import * as appsInstaller from '@x/core/dist/apps/installer.js';
|
||||
|
|
@ -108,6 +114,7 @@ import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index
|
|||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
import { claimReferralCode, getCreditsState, maybeActivateCredit, subscribeCreditActivations } from '@x/core/dist/billing/credits.js';
|
||||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
|
|
@ -657,6 +664,11 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro
|
|||
// prompt, so any OAuth state change must rebuild it.
|
||||
invalidateCopilotInstructionsCache();
|
||||
broadcastToWindows('oauth:didConnect', event);
|
||||
// Google connect (both BYOK and rowboat-mode paths funnel through here) is
|
||||
// the "connected Gmail" first-time reward.
|
||||
if (event.provider === 'google' && event.success) {
|
||||
void maybeActivateCredit('first_gmail_connected');
|
||||
}
|
||||
}
|
||||
|
||||
async function requireCodeSession(sessionId: string): Promise<CodeSession> {
|
||||
|
|
@ -833,6 +845,10 @@ export function stopServicesWatcher(): void {
|
|||
// Handler Implementations
|
||||
// ============================================================================
|
||||
|
||||
// app:consumeUpdateInfo returns `updatedFrom` at most once per app run, so a
|
||||
// renderer reload doesn't re-show the "updated to vX" card.
|
||||
let updateNoticeConsumed = false;
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
* Add new handlers here as you add channels to IPCChannels
|
||||
|
|
@ -841,6 +857,10 @@ export function setupIpcHandlers() {
|
|||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
// Relay backend-confirmed credit grants (first-time-action rewards) to all
|
||||
// windows so the UI can update balances and celebrate.
|
||||
subscribeCreditActivations((event) => broadcastToWindows('credits:didActivate', event));
|
||||
|
||||
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
|
||||
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
|
||||
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
|
||||
|
|
@ -856,6 +876,28 @@ export function setupIpcHandlers() {
|
|||
'app:consumePendingDeepLink': async () => {
|
||||
return { url: consumePendingDeepLink() };
|
||||
},
|
||||
'app:consumeUpdateInfo': async () => {
|
||||
const version = app.getVersion();
|
||||
if (updateNoticeConsumed) return { version, updatedFrom: null };
|
||||
updateNoticeConsumed = true;
|
||||
const changedFrom = recordAppVersion(version);
|
||||
// Downgrades still restamp (so the next upgrade reports correctly) but
|
||||
// don't toast "Updated to vX" or count as a client update.
|
||||
const updatedFrom = changedFrom && isVersionUpgrade(changedFrom, version) ? changedFrom : null;
|
||||
// 'app_updated' is taken by the in-app apps feature; this is the client itself.
|
||||
if (updatedFrom) capture('client_updated', { from: updatedFrom, to: version });
|
||||
return { version, updatedFrom };
|
||||
},
|
||||
'updater:getStatus': async () => {
|
||||
return getUpdaterStatus();
|
||||
},
|
||||
'updater:check': async () => {
|
||||
return checkForUpdates();
|
||||
},
|
||||
'updater:quitAndInstall': async () => {
|
||||
quitAndInstallUpdate();
|
||||
return {};
|
||||
},
|
||||
'app:consumePendingTrayCommand': async () => {
|
||||
return { toggleMeetingNotes: consumePendingToggleMeetingNotes() };
|
||||
},
|
||||
|
|
@ -961,7 +1003,11 @@ export function setupIpcHandlers() {
|
|||
return {};
|
||||
},
|
||||
'gmail:sendReply': async (_event, args) => {
|
||||
return sendThreadReply(args);
|
||||
const result = await sendThreadReply(args);
|
||||
if (!result.error) {
|
||||
void maybeActivateCredit('first_email_sent');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
'gmail:saveDraft': async (_event, args) => {
|
||||
return saveThreadDraft(args);
|
||||
|
|
@ -1204,10 +1250,21 @@ export function setupIpcHandlers() {
|
|||
}
|
||||
},
|
||||
'models:list': async () => {
|
||||
if (await isSignedIn()) {
|
||||
return await listGatewayModels();
|
||||
const base = (await isSignedIn())
|
||||
? await listGatewayModels()
|
||||
: await listOnboardingModels();
|
||||
// ChatGPT-subscription (codex) models are additive and independent of
|
||||
// Rowboat sign-in; their failure must never break the main list.
|
||||
try {
|
||||
const chatgpt = await getChatGPTStatus();
|
||||
if (chatgpt.signedIn) {
|
||||
const codex = await listCodexModels();
|
||||
return { providers: [...base.providers, ...codex.providers] };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Codex] Listing subscription models failed:', error);
|
||||
}
|
||||
return await listOnboardingModels();
|
||||
return base;
|
||||
},
|
||||
'models:test': async (_event, args) => {
|
||||
return await testModelConnection(args.provider, args.model);
|
||||
|
|
@ -1257,6 +1314,32 @@ export function setupIpcHandlers() {
|
|||
const config = await repo.getClientFacingConfig();
|
||||
return { config };
|
||||
},
|
||||
'chatgpt:getStatus': async () => {
|
||||
return await getChatGPTStatus();
|
||||
},
|
||||
'chatgpt:signIn': async () => {
|
||||
const result = await signInWithChatGPT();
|
||||
if (result.signedIn) {
|
||||
// Model lists gate on sign-in state (composer picker, models:list) —
|
||||
// push the change so they refresh without polling.
|
||||
broadcastToWindows('chatgpt:statusChanged', { signedIn: true });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
'chatgpt:cancelSignIn': async () => {
|
||||
await cancelChatGPTSignIn();
|
||||
return { success: true };
|
||||
},
|
||||
'chatgpt:signOut': async () => {
|
||||
try {
|
||||
await signOutChatGPT();
|
||||
broadcastToWindows('chatgpt:statusChanged', { signedIn: false });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Sign-out failed:', error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
'account:getRowboat': async () => {
|
||||
const signedIn = await isSignedIn();
|
||||
if (!signedIn) {
|
||||
|
|
@ -1732,6 +1815,13 @@ export function setupIpcHandlers() {
|
|||
lastAppsFingerprint = fingerprint;
|
||||
invalidateCopilotInstructionsCache();
|
||||
}
|
||||
// The copilot builds apps by writing the folder directly — apps:create is
|
||||
// never on that path — so the first-app reward triggers off observed
|
||||
// state instead: a valid non-installed app means the user built one.
|
||||
// Cheap on repeat polls (maybeActivateCredit short-circuits once claimed).
|
||||
if (apps.some((a) => a.kind === 'local' && a.status === 'ok')) {
|
||||
void maybeActivateCredit('first_app_built');
|
||||
}
|
||||
return {
|
||||
serverRunning: status.running,
|
||||
...(status.error ? { serverError: status.error } : {}),
|
||||
|
|
@ -1751,6 +1841,7 @@ export function setupIpcHandlers() {
|
|||
'apps:create': async (_event, args) => {
|
||||
const app = await appsIndexer.createApp(args);
|
||||
capture('app_created', { folder: app.folder });
|
||||
void maybeActivateCredit('first_app_built');
|
||||
return { app };
|
||||
},
|
||||
'apps:delete': async (_event, args) => {
|
||||
|
|
@ -2124,6 +2215,9 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'meeting:summarize': async (_event, args) => {
|
||||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||
if (notes && notes.trim()) {
|
||||
void maybeActivateCredit('first_meeting_note');
|
||||
}
|
||||
return { notes };
|
||||
},
|
||||
'meeting-prep:resolve': async (_event, args) => {
|
||||
|
|
@ -2415,6 +2509,7 @@ export function setupIpcHandlers() {
|
|||
...(args.model ? { model: args.model } : {}),
|
||||
...(args.provider ? { provider: args.provider } : {}),
|
||||
});
|
||||
void maybeActivateCredit('first_bg_agent');
|
||||
return { success: true, slug };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
@ -2451,6 +2546,13 @@ export function setupIpcHandlers() {
|
|||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
// First-time-action credit rewards
|
||||
'credits:getState': async () => {
|
||||
return await getCreditsState();
|
||||
},
|
||||
'referral:claim': async (_event, args) => {
|
||||
return await claimReferralCode(args.code);
|
||||
},
|
||||
'notifications:getSettings': async () => {
|
||||
return loadNotificationSettings();
|
||||
},
|
||||
|
|
@ -2458,6 +2560,13 @@ export function setupIpcHandlers() {
|
|||
saveNotificationSettings(args);
|
||||
return { success: true };
|
||||
},
|
||||
'turnLimits:getSettings': async () => {
|
||||
return await loadTurnLimitsSettings();
|
||||
},
|
||||
'turnLimits:setSettings': async (_event, args) => {
|
||||
await saveTurnLimitsSettings(args);
|
||||
return { success: true };
|
||||
},
|
||||
// Embedded browser handlers (WebContentsView + navigation)
|
||||
...browserIpcHandlers,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { disposeAllTerminals } from "./terminal.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||
import { initUpdater } from "./updater.js";
|
||||
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
|
||||
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
|
||||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||
|
|
@ -40,6 +40,7 @@ import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/asse
|
|||
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 { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
|
||||
import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.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";
|
||||
|
|
@ -56,6 +57,7 @@ import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
|||
import type { ISessions } from "@x/core/dist/runtime/sessions/index.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { setupBrowserExtensions } from "./browser/extensions.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
||||
import {
|
||||
|
|
@ -227,52 +229,35 @@ protocol.registerSchemesAsPrivileged([
|
|||
|
||||
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
|
||||
|
||||
// On Linux, Chromium's loopback capture records the default sink's monitor
|
||||
// through the PulseAudio layer, at the monitor source's own volume. Desktop
|
||||
// tools sometimes leave that volume near zero — it's invisible plumbing that
|
||||
// doesn't affect what the user hears — which turns the whole capture into
|
||||
// digital silence with no error anywhere. Raise it back to 100% before
|
||||
// capture starts (raise only, so a deliberate >100% boost is left alone).
|
||||
// Best-effort: no pactl or no Pulse layer just skips.
|
||||
async function ensureLinuxMonitorVolume(): Promise<void> {
|
||||
const execFileP = promisify(execFile);
|
||||
try {
|
||||
const { stdout: sinkOut } = await execFileP("pactl", ["get-default-sink"], { timeout: 3000 });
|
||||
const monitor = `${sinkOut.trim()}.monitor`;
|
||||
const { stdout: volOut } = await execFileP("pactl", ["get-source-volume", monitor], { timeout: 3000 });
|
||||
const percents = [...volOut.matchAll(/(\d+)%/g)].map((m) => Number(m[1]));
|
||||
if (percents.length === 0 || Math.min(...percents) >= 100) return;
|
||||
await execFileP("pactl", ["set-source-volume", monitor, "100%"], { timeout: 3000 });
|
||||
console.log(`[meeting] Raised ${monitor} volume from ${Math.min(...percents)}% to 100% for system-audio capture`);
|
||||
} catch {
|
||||
// pactl missing or non-Pulse audio stack — nothing to fix here.
|
||||
}
|
||||
}
|
||||
// Granted to the embedded browser partition on top of the base set.
|
||||
// `notifications` lets sites (WhatsApp Web, Gmail, Slack, ...) show native OS
|
||||
// notifications via the HTML5 Notification API — Electron renders these
|
||||
// through the system notification center once the permission resolves to
|
||||
// granted. Background Web Push is still unavailable (Electron has no FCM),
|
||||
// so notifications only fire while the site is loaded in a tab. The app's
|
||||
// own renderer keeps the base set; it notifies through the main-process
|
||||
// notification service instead.
|
||||
const BROWSER_EXTRA_PERMISSIONS = ["notifications"] as const;
|
||||
|
||||
function configureSessionPermissions(targetSession: Session, extraPermissions: readonly string[] = []): void {
|
||||
const allowed = new Set([...ALLOWED_SESSION_PERMISSIONS, ...extraPermissions]);
|
||||
|
||||
function configureSessionPermissions(targetSession: Session): void {
|
||||
targetSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
return ALLOWED_SESSION_PERMISSIONS.has(permission);
|
||||
return allowed.has(permission);
|
||||
});
|
||||
|
||||
targetSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
callback(ALLOWED_SESSION_PERMISSIONS.has(permission));
|
||||
callback(allowed.has(permission));
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-approve display media requests and route system audio as loopback.
|
||||
// Electron requires a video source in the callback even if we only want audio.
|
||||
// We pass the first available screen source; the renderer discards the video track.
|
||||
targetSession.setDisplayMediaRequestHandler(async (request, callback) => {
|
||||
// On Linux, enumerating screens via desktopCapturer goes through the
|
||||
// Wayland screencast portal, which can block on a system dialog or hang
|
||||
// outright. Requests that want audio (meeting transcription — the only
|
||||
// audio consumer; it discards the video track) don't need a real screen,
|
||||
// so answer with the requesting frame as the mandatory video source and
|
||||
// Chromium's PulseAudio loopback for the audio.
|
||||
if (process.platform === 'linux' && request.audioRequested && request.frame) {
|
||||
await ensureLinuxMonitorVolume();
|
||||
callback({ video: request.frame, audio: 'loopback' });
|
||||
return;
|
||||
}
|
||||
// Auto-approve display media requests and route system audio as loopback.
|
||||
// Electron requires a video source in the callback even if we only want audio.
|
||||
// We pass the first available screen source; the renderer discards the video track.
|
||||
// App session only — the embedded browser partition registers its own handler
|
||||
// (a user-facing source picker) in BrowserViewManager.
|
||||
function configureAppDisplayMediaHandler(targetSession: Session): void {
|
||||
targetSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
const sources = await desktopCapturer.getSources({ types: ['screen'] });
|
||||
if (sources.length === 0) {
|
||||
callback({});
|
||||
|
|
@ -380,7 +365,8 @@ function createWindow(options: { startHidden?: boolean } = {}) {
|
|||
});
|
||||
|
||||
configureSessionPermissions(session.defaultSession);
|
||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
||||
configureAppDisplayMediaHandler(session.defaultSession);
|
||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION), BROWSER_EXTRA_PERMISSIONS);
|
||||
|
||||
mainWindow = win;
|
||||
setMainWindowForDeepLinks(win);
|
||||
|
|
@ -465,16 +451,9 @@ app.whenReady().then(async () => {
|
|||
// serves workspace files via app://workspace/<rel-path> for media previews.
|
||||
registerAppProtocol();
|
||||
|
||||
// Initialize auto-updater (only in production)
|
||||
if (app.isPackaged) {
|
||||
updateElectronApp({
|
||||
updateSource: {
|
||||
type: UpdateSourceType.ElectronPublicUpdateService,
|
||||
repo: "rowboatlabs/rowboat",
|
||||
},
|
||||
notifyUser: true, // Shows native dialog when update is available
|
||||
});
|
||||
}
|
||||
// Initialize auto-updater (no-ops in dev). Update state is pushed to the
|
||||
// renderer (updater:status), which owns the restart prompt — see updater.ts.
|
||||
initUpdater();
|
||||
|
||||
// The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
|
||||
// and is resolved per call by the shared executor in @x/core. Availability is
|
||||
|
|
@ -505,6 +484,7 @@ app.whenReady().then(async () => {
|
|||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
setupBrowserExtensions();
|
||||
|
||||
// Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE
|
||||
// the window and the long service-init chain below. The Apps view is
|
||||
|
|
@ -520,6 +500,12 @@ app.whenReady().then(async () => {
|
|||
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
|
||||
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
|
||||
});
|
||||
// ChatGPT subscription tokens at rest: same keychain-backed cipher.
|
||||
setChatGPTTokenCipher({
|
||||
isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
|
||||
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
|
||||
});
|
||||
initAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to start:', error);
|
||||
});
|
||||
|
|
|
|||
164
apps/x/apps/main/src/updater.ts
Normal file
164
apps/x/apps/main/src/updater.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { app, autoUpdater, net, nativeImage, BrowserWindow } from "electron";
|
||||
import { capture } from "@x/core/dist/analytics/posthog.js";
|
||||
import type { ipc } from "@x/shared";
|
||||
|
||||
export type UpdaterStatus = ipc.IPCChannels["updater:status"]["req"];
|
||||
|
||||
const REPO = "rowboatlabs/rowboat";
|
||||
const CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
let status: UpdaterStatus = { state: "disabled", version: "", reason: "dev" };
|
||||
|
||||
function setStatus(next: Omit<UpdaterStatus, "version">): void {
|
||||
status = { version: status.version, ...next };
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send("updater:status", status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpdaterStatus(): UpdaterStatus {
|
||||
return status;
|
||||
}
|
||||
|
||||
// 32x32 green dot with a white ring (scratchpad-generated PNG). Windows'
|
||||
// counterpart of the macOS dock badge: overlays the taskbar icon while an
|
||||
// update is staged. Cleared implicitly — installing quits the process.
|
||||
const WIN_BADGE_DATA_URL =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABPUlEQVR42s1XOwoCMRC12CvkAhb2HmMvYS/kCgveQU9gYS17AKu1EKwXsdDCwtQWtk9GkiUbkv2RkAw8WLLJzEvmk8kMwCwmpixiAHIAHEAhweUYC0Ugk0Yq9Esl52a+CJAygfEi5NrJBOg4S5vm6+eOgzhh+zr+Qd805pCyyzUu4wsAta7l+X1j89hjeVljfl5ZQf9oDs01pJY6BxFgpnHapcuoC7TGQoINIdA6dn7bjTauQGst7ugkwH0Z7yDBXQQyPdqnHPtAdwg9Ra27pyDyZVzBCExuI9AUGYpk3wRIp1GsWgSY/rcr1aaCdBrCdAK5XmR8G1cwilWuE2j8T1UtFAHSbcaBIlCEiP6ebCiSIhDdBdGDMHoaRi9ESZTi6JdR9Os4iYYkiZYselOaRFuexMMkmadZEo/TYPgB7Se8LkyPD5UAAAAASUVORK5CYII=";
|
||||
|
||||
function showReadyBadge(): void {
|
||||
if (process.platform === "darwin") {
|
||||
// The window may be closed for days on macOS (app keeps running) — the
|
||||
// dock badge is the only surface that says "an update is waiting".
|
||||
app.dock?.setBadge("1");
|
||||
} else if (process.platform === "win32") {
|
||||
const badge = nativeImage.createFromDataURL(WIN_BADGE_DATA_URL);
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.setOverlayIcon(badge, "Update ready — restart to install");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-update, driving Electron's autoUpdater (Squirrel) against
|
||||
* update.electronjs.org directly. Events are forwarded to the renderer
|
||||
* (updater:status), which shows a "Restart to update" card once the update
|
||||
* is staged. By then Squirrel has already installed it — the card only asks
|
||||
* for the restart, Chrome-style. Must be called after app ready (it is —
|
||||
* from whenReady() in main.ts).
|
||||
*/
|
||||
export function initUpdater(): void {
|
||||
const version = app.getVersion();
|
||||
|
||||
if (!app.isPackaged) {
|
||||
status = { state: "disabled", version, reason: "dev" };
|
||||
return;
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
// Electron's autoUpdater doesn't support Linux (deb/zip installs).
|
||||
status = { state: "unsupported", version, reason: "platform" };
|
||||
return;
|
||||
}
|
||||
if (process.platform === "darwin" && !app.isInApplicationsFolder()) {
|
||||
// Squirrel.Mac swaps the .app bundle in place, which fails outside
|
||||
// /Applications (DMG mount, ~/Downloads). Don't wire the updater —
|
||||
// Settings > Help tells the user to move the app.
|
||||
status = { state: "unsupported", version, reason: "not-in-applications" };
|
||||
return;
|
||||
}
|
||||
|
||||
status = { state: "idle", version };
|
||||
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
setStatus({ state: "checking", lastCheckedAt: status.lastCheckedAt });
|
||||
});
|
||||
autoUpdater.on("update-available", () => {
|
||||
setStatus({ state: "downloading" });
|
||||
});
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
setStatus({ state: "idle", lastCheckedAt: Date.now() });
|
||||
});
|
||||
autoUpdater.on("update-downloaded", (_event, releaseNotes, releaseName) => {
|
||||
// macOS (Squirrel.Mac fed by update.electronjs.org) supplies both the
|
||||
// release name and the GitHub release body; Squirrel.Windows only the
|
||||
// name. When notes are missing the card shows a static fallback line.
|
||||
setStatus({
|
||||
state: "ready",
|
||||
newVersion: releaseName || undefined,
|
||||
releaseNotes: releaseNotes || undefined,
|
||||
});
|
||||
showReadyBadge();
|
||||
// Squirrel.Windows never carries notes, and Squirrel.Mac's copy is a
|
||||
// snapshot from download time — stale when the release body is edited
|
||||
// after publish (update.electronjs.org caching widens that window).
|
||||
// Refresh from the GitHub API; on failure the snapshot (or the card's
|
||||
// static fallback line) stands.
|
||||
void backfillReleaseNotes(releaseName || undefined);
|
||||
});
|
||||
autoUpdater.on("error", (err) => {
|
||||
setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt });
|
||||
capture("update_failed", { message: err.message });
|
||||
});
|
||||
|
||||
// update.electronjs.org serves both Squirrel dialects from one URL:
|
||||
// Squirrel.Mac GETs it as-is (204 = up to date, JSON = update; that legacy
|
||||
// format is serverType "default"), Squirrel.Windows appends /RELEASES.
|
||||
autoUpdater.setFeedURL({
|
||||
url: `https://update.electronjs.org/${REPO}/${process.platform}-${process.arch}/${version}`,
|
||||
serverType: "default",
|
||||
});
|
||||
// Check now and every 10 minutes, through the same guard as the manual
|
||||
// check: a tick is a no-op while a check/download is in flight or an
|
||||
// update is already staged.
|
||||
checkForUpdates();
|
||||
setInterval(checkForUpdates, CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the staged update's release notes with the current GitHub release
|
||||
* body. releaseName is "0.7.7" from Squirrel.Windows and "v0.7.7" from
|
||||
* Squirrel.Mac — normalize to the tag form.
|
||||
*/
|
||||
async function backfillReleaseNotes(releaseName: string | undefined): Promise<void> {
|
||||
if (!releaseName) return;
|
||||
try {
|
||||
const tag = `v${releaseName.replace(/^v/, "")}`;
|
||||
const res = await net.fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
|
||||
headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const { body } = (await res.json()) as { body?: string };
|
||||
const notes = body?.trim();
|
||||
// Re-check the state: the fetch raced user actions (quitAndInstall).
|
||||
if (notes && status.state === "ready" && status.newVersion === releaseName) {
|
||||
setStatus({ ...status, releaseNotes: notes });
|
||||
}
|
||||
} catch {
|
||||
// Offline or rate-limited — the Squirrel snapshot / fallback line stands.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual "Check for updates". Only meaningful when idle or errored;
|
||||
* checking/downloading are already in flight and ready is already staged.
|
||||
* Returns the snapshot after initiating.
|
||||
*/
|
||||
export function checkForUpdates(): UpdaterStatus {
|
||||
if (status.state === "idle" || status.state === "error") {
|
||||
try {
|
||||
autoUpdater.checkForUpdates();
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setStatus({ state: "error", error: error.message, lastCheckedAt: status.lastCheckedAt });
|
||||
capture("update_failed", { message: error.message });
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export function quitAndInstallUpdate(): void {
|
||||
capture("update_restarted", { from: status.version, to: status.newVersion });
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@
|
|||
"build": "rm -rf dist && tsc && esbuild dist/preload.js --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload.bundle.js && mv dist/preload.bundle.js dist/preload.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x/shared": "workspace:*"
|
||||
"@x/shared": "workspace:*",
|
||||
"electron-chrome-extensions": "^4.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^39.2.7",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
|
||||
import { injectBrowserAction } from 'electron-chrome-extensions/browser-action';
|
||||
import { ipc as ipcShared } from '@x/shared';
|
||||
|
||||
// Expose the <browser-action-list> custom element (extension action icons +
|
||||
// popups for the embedded browser pane). App documents only — this preload
|
||||
// is attached solely to the app window, but guard against it ever being
|
||||
// reused for remote content.
|
||||
if (location.protocol === 'app:' || location.origin === 'http://localhost:5173') {
|
||||
try {
|
||||
injectBrowserAction();
|
||||
} catch (error) {
|
||||
console.error('[preload] injectBrowserAction failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeChannels = ipcShared.InvokeChannels;
|
||||
type IPCChannels = ipcShared.IPCChannels;
|
||||
type SendChannels = ipcShared.SendChannels;
|
||||
|
|
|
|||
|
|
@ -1445,3 +1445,10 @@
|
|||
transform: translateX(-120%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Issue #749: BiDi. unicode-bidi:plaintext resolves direction PER BLOCK from
|
||||
its first strong character, so a message mixing English + RTL paragraphs
|
||||
aligns each block correctly. text-align:start follows the resolved dir. */
|
||||
.bidi-auto :is(p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th){unicode-bidi:plaintext;text-align:start;}
|
||||
/* Code is inherently LTR — keep it stable inside RTL messages. */
|
||||
.bidi-auto :is(pre,code,kbd,samp){direction:ltr;unicode-bidi:isolate;text-align:left;}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { UpdateCard } from "@/components/update-card"
|
||||
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
||||
import { CreditCelebration } from "@/components/credit-celebration"
|
||||
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
|
||||
import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
|
|
@ -3019,6 +3021,13 @@ function App() {
|
|||
// via the agent resolver; keep them session-sticky where possible so the
|
||||
// provider prefix cache survives across turns.
|
||||
const reasoningEffort = reasoningEffortByTabRef.current.get(submitTabId)
|
||||
// The runtime defaults omitted maxModelCalls to the global limit; the
|
||||
// chat-specific override is the UI's job to pass explicitly. A failed
|
||||
// settings read just falls back to the global limit.
|
||||
const chatMaxModelCalls = await window.ipc
|
||||
.invoke('turnLimits:getSettings', null)
|
||||
.then((settings) => settings.chatMaxModelCalls)
|
||||
.catch(() => undefined)
|
||||
const sendConfig = {
|
||||
agent: {
|
||||
agentId,
|
||||
|
|
@ -3037,6 +3046,7 @@ function App() {
|
|||
},
|
||||
autoPermission: (permissionMode ?? 'manual') === 'auto',
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(chatMaxModelCalls !== undefined ? { maxModelCalls: chatMaxModelCalls } : {}),
|
||||
}
|
||||
const userMessageContextFor = (middlePane: Awaited<ReturnType<typeof buildMiddlePaneContext>>) => ({
|
||||
currentDateTime: new Date().toISOString(),
|
||||
|
|
@ -4280,6 +4290,9 @@ function App() {
|
|||
setEmailInitialThreadId(threadId)
|
||||
setEmailThreadIdVersion((v) => v + 1)
|
||||
}
|
||||
// Same reason as in navigateToView: a stale assistant-driven search must
|
||||
// not repopulate the search box when the user re-enters the email view.
|
||||
setEmailInitialSearchQuery(null)
|
||||
ensureEmailFileTab()
|
||||
}, [ensureEmailFileTab])
|
||||
|
||||
|
|
@ -4454,6 +4467,10 @@ function App() {
|
|||
if (view.searchQuery) {
|
||||
setEmailInitialSearchQuery(view.searchQuery)
|
||||
setEmailSearchQueryVersion((v) => v + 1)
|
||||
} else {
|
||||
// Otherwise a past assistant-driven search would be re-applied on
|
||||
// every re-entry, even after the user cleared the search box.
|
||||
setEmailInitialSearchQuery(null)
|
||||
}
|
||||
ensureEmailFileTab()
|
||||
return
|
||||
|
|
@ -4703,6 +4720,23 @@ function App() {
|
|||
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
|
||||
}, [])
|
||||
|
||||
// "Updated to vX.Y.Z" card on the first launch after an update. Main
|
||||
// compares its persisted version stamp against the running version and
|
||||
// hands out `updatedFrom` exactly once, so reloads don't re-show this.
|
||||
useEffect(() => {
|
||||
void window.ipc.invoke('app:consumeUpdateInfo', null).then(({ version, updatedFrom }) => {
|
||||
if (!updatedFrom) return
|
||||
toast(`Updated to v${version}`, {
|
||||
description: `Rowboat was updated from v${updatedFrom}.`,
|
||||
action: {
|
||||
label: "What's new",
|
||||
onClick: () => window.open(`https://github.com/rowboatlabs/rowboat/releases/tag/v${version}`, '_blank'),
|
||||
},
|
||||
duration: 10000,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 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(() => {
|
||||
|
|
@ -6266,6 +6300,7 @@ function App() {
|
|||
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||
onOpenApps={openAppsView}
|
||||
onOpenApp={(folder) => { setAppInitialId(folder); setAppIdVersion((v) => v + 1); openAppsView() }}
|
||||
recentRuns={runs}
|
||||
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||
onRenameRun={(rid, title) => {
|
||||
|
|
@ -7186,6 +7221,8 @@ function App() {
|
|||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<UpdateCard />
|
||||
<CreditCelebration />
|
||||
<BillingErrorDialog
|
||||
open={billingErrorOpen}
|
||||
match={billingErrorMatch}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export const AskHumanRequest = ({
|
|||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { isValidElement, type JSX } from 'react'
|
||||
import { FilePathCard } from './file-path-card'
|
||||
import { MermaidRenderer } from '@/components/mermaid-renderer'
|
||||
import { ChartRenderer } from '@/components/chart-renderer'
|
||||
|
||||
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||
const { children, ...rest } = props
|
||||
|
|
@ -31,6 +32,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
|||
return <MermaidRenderer source={text} />
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof childProps.className === 'string' &&
|
||||
childProps.className.includes('language-chart')
|
||||
) {
|
||||
const text = typeof childProps.children === 'string'
|
||||
? childProps.children.trim()
|
||||
: ''
|
||||
if (text) {
|
||||
return <ChartRenderer source={text} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Passthrough for all other code blocks - return children directly
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ export const MessageResponse = memo(
|
|||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 bidi-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1162,6 +1162,7 @@ export const PromptInputTextarea = ({
|
|||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden="true"
|
||||
dir="auto"
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-sm text-transparent"
|
||||
>
|
||||
{mentionHighlights.segments.map((segment, index) =>
|
||||
|
|
@ -1180,6 +1181,7 @@ export const PromptInputTextarea = ({
|
|||
)}
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
className={cn("relative z-10 !p-0 field-sizing-content max-h-48 min-h-10", className)}
|
||||
name="message"
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { PublishDialog } from '@/components/apps/publish-dialog'
|
||||
import { unpinApp } from '@/lib/pinned-apps'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
|
||||
// App detail panel (spec §14): manifest info, provenance/publish state,
|
||||
|
|
@ -79,6 +80,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
|
||||
if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return
|
||||
await window.ipc.invoke('apps:uninstall', { folder })
|
||||
unpinApp(folder)
|
||||
onClose()
|
||||
})
|
||||
|
||||
|
|
@ -88,6 +90,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
const publishNote = app?.publish ? '\n\nThe published copy (GitHub repo + catalog listing) is not touched.' : ''
|
||||
if (!window.confirm(`Delete this app? The whole folder, including data/, is removed from this machine.${publishNote}${agentNote}`)) return
|
||||
await window.ipc.invoke('apps:delete', { folder })
|
||||
unpinApp(folder)
|
||||
onClose()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,20 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
import { PanelLeft, PanelLeftClose, Plus, RefreshCw } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { AppFrame } from '@/components/apps/app-frame'
|
||||
import { CatalogTab } from '@/components/apps/catalog'
|
||||
import { themeForIndex, patternFor } from '@/components/apps/card-theme'
|
||||
import { getPinnedApps, onPinnedAppsChanged, pinApp, unpinApp } from '@/lib/pinned-apps'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
|
||||
// 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;
|
||||
|
|
@ -61,6 +49,14 @@ const CARD_CSS = `
|
|||
.ma-inner { max-width:1120px; margin:0 auto; padding:34px 30px 48px; }
|
||||
.ma-h1 { font-size: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-hint { font-size:12.5px; color:var(--ma-sub); margin:-6px 0 clamp(12px,1.8cqw,16px); }
|
||||
.ma-welcome {
|
||||
border:1px solid var(--ma-border); border-radius:12px; padding:12px 16px;
|
||||
font-size:13.5px; color:var(--ma-desc); margin-bottom:clamp(14px,2cqw,20px);
|
||||
background:color-mix(in srgb, var(--ma-title) 4%, transparent);
|
||||
}
|
||||
.ma-welcome button { color:var(--ma-title); font-weight:600; text-decoration:underline; text-underline-offset:3px; background:none; border:none; padding:0; font-size:inherit; cursor:pointer; }
|
||||
.ma-owner { font-size:12px; color:var(--ma-sub); margin:-4px 0 8px; }
|
||||
.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); }
|
||||
|
|
@ -134,31 +130,47 @@ const CARD_CSS = `
|
|||
}
|
||||
`
|
||||
|
||||
function Card({ app, index, onOpen }: { app: rowboatApp.AppSummary; index: number; onOpen: () => void }) {
|
||||
function Card({ app, index, onOpen, isPinned, onTogglePin }: {
|
||||
app: rowboatApp.AppSummary
|
||||
index: number
|
||||
onOpen: () => void
|
||||
isPinned: boolean
|
||||
onTogglePin: () => 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>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<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>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onTogglePin}>
|
||||
{isPinned ? <PanelLeftClose className="mr-2 size-3.5" /> : <PanelLeft className="mr-2 size-3.5" />}
|
||||
{isPinned ? 'Remove from sidebar' : 'Add to sidebar'}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -167,10 +179,16 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
initialVersion?: number
|
||||
onNewApp?: () => void
|
||||
} = {}) {
|
||||
const [tab, setTab] = useState<'mine' | 'catalog'>('mine')
|
||||
// null = auto: land on "My apps" normally, but fall through to the catalog
|
||||
// until the user has an app of their own. An explicit tab click wins.
|
||||
const [tab, setTab] = useState<'mine' | 'catalog' | null>(null)
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | null>(initialAppFolder ?? null)
|
||||
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
|
||||
const [appsLoaded, setAppsLoaded] = useState(false)
|
||||
const [serverError, setServerError] = useState<string | null>(null)
|
||||
const [pinnedFolders, setPinnedFolders] = useState<string[]>(() => getPinnedApps())
|
||||
|
||||
useEffect(() => onPinnedAppsChanged(setPinnedFolders), [])
|
||||
|
||||
// Open a specific app when asked from outside (app-navigation open-app).
|
||||
const [appliedVersion, setAppliedVersion] = useState(initialVersion)
|
||||
|
|
@ -186,11 +204,19 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
const r = await window.ipc.invoke('apps:list', {})
|
||||
if (cancelled) return
|
||||
setApps(r.apps)
|
||||
setAppsLoaded(true)
|
||||
// Drop a selection whose app no longer exists (uninstalled while
|
||||
// open). Left stale, a later reinstall makes this view yank the user
|
||||
// into the app frame mid-flow — e.g. while they're in the catalog's
|
||||
// post-install agent dialog.
|
||||
setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur))
|
||||
// Prune sidebar pins for apps that no longer exist (uninstalled via
|
||||
// the copilot or another window) — but only off an authoritative
|
||||
// list, never while the apps server is down.
|
||||
if (r.serverRunning) {
|
||||
const live = new Set(r.apps.map((a) => a.folder))
|
||||
for (const f of getPinnedApps()) if (!live.has(f)) unpinApp(f)
|
||||
}
|
||||
setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
|
||||
} catch (e) {
|
||||
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
|
||||
|
|
@ -206,6 +232,9 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
|
||||
}
|
||||
|
||||
const noOwnApps = appsLoaded && apps.length === 0
|
||||
const activeTab = tab ?? (noOwnApps ? 'catalog' : 'mine')
|
||||
|
||||
return (
|
||||
<div className="ma-page">
|
||||
<style>{CARD_CSS}</style>
|
||||
|
|
@ -214,8 +243,8 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
<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>
|
||||
<button type="button" className={`ma-tab${activeTab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
|
||||
<button type="button" className={`ma-tab${activeTab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
|
|
@ -224,19 +253,39 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'catalog' ? (
|
||||
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
||||
{!appsLoaded && !serverError ? null : activeTab === 'catalog' ? (
|
||||
<>
|
||||
{noOwnApps && (
|
||||
<div className="ma-welcome">
|
||||
You don't have any apps of your own yet. Install one from this catalog, or{' '}
|
||||
<button type="button" onClick={onNewApp}>build your own</button>.
|
||||
</div>
|
||||
)}
|
||||
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<>
|
||||
{apps.length > 0 && (
|
||||
<p className="ma-hint">Tip: right-click an app to add it to the sidebar for quick access.</p>
|
||||
)}
|
||||
<div className="ma-grid">
|
||||
{apps.map((app, i) => (
|
||||
<Card
|
||||
key={app.folder}
|
||||
app={app}
|
||||
index={i}
|
||||
onOpen={() => setSelectedFolder(app.folder)}
|
||||
isPinned={pinnedFolders.includes(app.folder)}
|
||||
onTogglePin={() => (pinnedFolders.includes(app.folder) ? unpinApp(app.folder) : pinApp(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>
|
||||
|
|
|
|||
23
apps/x/apps/renderer/src/components/apps/card-theme.ts
Normal file
23
apps/x/apps/renderer/src/components/apps/card-theme.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Deterministic accent/pattern assignment shared by the "My apps" grid and
|
||||
// the catalog grid, so both render the same card visual language.
|
||||
|
||||
export type CardTheme = { accent: string; glow: string }
|
||||
|
||||
const THEMES: CardTheme[] = [
|
||||
{ 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)
|
||||
}
|
||||
|
||||
export const themeForIndex = (i: number): CardTheme => THEMES[i % THEMES.length]
|
||||
export const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length]
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { themeForIndex, patternFor } from '@/components/apps/card-theme'
|
||||
import { ModelSelector } from '@/components/model-selector'
|
||||
|
||||
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
||||
// disclosure, install from a direct bundle URL.
|
||||
|
|
@ -88,26 +90,9 @@ function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSk
|
|||
onEnable: (model: ModelChoice | null) => void
|
||||
onSkip: () => void
|
||||
}) {
|
||||
const [options, setOptions] = useState<Array<ModelChoice & { label: string }>>([])
|
||||
const [selected, setSelected] = useState<string>(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '')
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('models:list', null)
|
||||
setOptions(r.providers.flatMap((p) => p.models.map((m) => ({
|
||||
provider: p.id,
|
||||
model: m.id,
|
||||
label: `${m.name ?? m.id} (${p.name})`,
|
||||
}))))
|
||||
} catch { /* no picker — enable keeps the pinned model */ }
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const choice = (): ModelChoice | null => {
|
||||
const [provider, model] = selected.split('::')
|
||||
return provider && model ? { provider, model } : null
|
||||
}
|
||||
// Pre-seeded with the host-pinned model; "(default)" (null) enables
|
||||
// without a model patch, keeping whatever each agent has pinned.
|
||||
const [selected, setSelected] = useState<ModelChoice | null>(defaultModel ?? null)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
|
|
@ -122,24 +107,21 @@ function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSk
|
|||
<ul className="mb-3 list-inside list-disc text-sm text-muted-foreground">
|
||||
{names.map((n) => <li key={n}>{n}</li>)}
|
||||
</ul>
|
||||
{options.length > 0 && (
|
||||
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Run with
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)}
|
||||
className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm">
|
||||
{defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && (
|
||||
<option value={`${defaultModel.provider}::${defaultModel.model}`}>{defaultModel.model} (default)</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={`${o.provider}::${o.model}`} value={`${o.provider}::${o.model}`}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Run with
|
||||
<div className="min-w-0 flex-1">
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
inheritDefault={{ label: '(default)' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onSkip} disabled={busy}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Not now</button>
|
||||
<button type="button" onClick={() => onEnable(choice())} disabled={busy}
|
||||
<button type="button" onClick={() => onEnable(selected)} disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
{busy ? 'Turning on…' : 'Turn on & run now'}
|
||||
</button>
|
||||
|
|
@ -338,37 +320,58 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
|||
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ranked.map((r) => (
|
||||
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="truncate text-sm font-semibold">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">by {r.owner}</span>
|
||||
<div className="ma-grid">
|
||||
{ranked.map((r, i) => {
|
||||
const theme = themeForIndex(i)
|
||||
const installedFolder = installedByName.get(r.name)
|
||||
return (
|
||||
<div
|
||||
key={r.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => installedFolder ? onInstalled(installedFolder) : void startInstall(r.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (installedFolder) onInstalled(installedFolder)
|
||||
else void startInstall(r.name)
|
||||
}
|
||||
}}
|
||||
className={`ma-card ma-pat-${patternFor(r.name)}`}
|
||||
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
||||
>
|
||||
<div className="ma-top">
|
||||
{installedFolder && <span className="ma-badge">INSTALLED</span>}
|
||||
<button type="button"
|
||||
title={starred[r.repo] ? 'Unstar on GitHub' : 'Star on GitHub'}
|
||||
onClick={(e) => { e.stopPropagation(); void toggleStar(r.repo) }}
|
||||
className={`flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium hover:bg-foreground/10 ${starred[r.repo] ? 'text-amber-500' : 'text-muted-foreground'}`}>
|
||||
<Star className={`size-3.5 ${starred[r.repo] ? 'fill-current' : ''}`} />
|
||||
{stars[r.repo] ?? '—'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ma-title">{r.name}</div>
|
||||
<div className="ma-owner">by {r.owner}</div>
|
||||
<div className="ma-desc">{r.description || 'No description.'}</div>
|
||||
<div className="ma-footer">
|
||||
<span className="ma-lastrun">{r.repo}</span>
|
||||
{installedFolder ? (
|
||||
<button type="button" title="Installed — open it"
|
||||
onClick={(e) => { e.stopPropagation(); onInstalled(installedFolder) }}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
|
||||
<BadgeCheck className="size-4" /> Open
|
||||
</button>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={(e) => { e.stopPropagation(); void startInstall(r.name) }}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
<Download className="size-4" /> Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
title={starred[r.repo] ? 'Unstar on GitHub' : 'Star on GitHub'}
|
||||
onClick={() => void toggleStar(r.repo)}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium hover:bg-accent ${starred[r.repo] ? 'text-amber-500' : 'text-muted-foreground'}`}>
|
||||
<Star className={`size-3.5 ${starred[r.repo] ? 'fill-current' : ''}`} />
|
||||
{stars[r.repo] ?? '—'}
|
||||
</button>
|
||||
{installedByName.has(r.name) ? (
|
||||
<button type="button" title="Installed — open it"
|
||||
onClick={() => onInstalled(installedByName.get(r.name)!)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
|
||||
<BadgeCheck className="size-4" /> Installed
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => void startInstall(r.name)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
<Download className="size-4" /> Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation'
|
|||
import { fetchAgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
||||
|
|
@ -924,19 +925,13 @@ function SetupTab({
|
|||
{showAdvanced && (
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-[74px_1fr] gap-x-3 gap-y-2.5 text-xs">
|
||||
<span className="pt-1.5 text-muted-foreground">Model</span>
|
||||
<Input
|
||||
value={draft.model ?? ''}
|
||||
onChange={e => setDraft({ ...draft, model: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
<span className="pt-1.5 text-muted-foreground">Provider</span>
|
||||
<Input
|
||||
value={draft.provider ?? ''}
|
||||
onChange={e => setDraft({ ...draft, provider: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
<span className="pt-2 text-muted-foreground">Model</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
value={modelOverrideToRef(draft.model, draft.provider)}
|
||||
onChange={(ref) => setDraft({ ...draft, ...refToModelOverride(ref) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,25 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
|
||||
|
||||
import type { HttpAuthRequest } from '@x/shared/dist/browser-control.js'
|
||||
// Custom element provided by electron-chrome-extensions (injected via the
|
||||
// preload script): a row of extension action icons for the given session
|
||||
// partition, with popup handling built in. Type names resolve against the
|
||||
// react module's own scope inside this augmentation.
|
||||
declare module 'react' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'browser-action-list': import('react').DetailedHTMLProps<
|
||||
import('react').HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
> & {
|
||||
partition?: string
|
||||
alignment?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import type { DisplayMediaRequest, DisplayMediaSource, HttpAuthRequest } from '@x/shared/dist/browser-control.js'
|
||||
|
||||
import { TabBar } from '@/components/tab-bar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -168,10 +186,127 @@ function BrowserHttpAuthDialog({
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Source picker for getDisplayMedia() requests raised by pages in the
|
||||
* embedded browser (e.g. Google Meet "Present now"). Mirrors Chrome's share
|
||||
* dialog: pick a screen or window, optionally include system audio (screens
|
||||
* only — loopback capture is system-wide, so it isn't offered per-window).
|
||||
*/
|
||||
function BrowserScreenSharePickerDialog({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
request: DisplayMediaRequest
|
||||
onSubmit: (sourceId: string, audio: boolean) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [shareAudio, setShareAudio] = useState(false)
|
||||
|
||||
const screens = request.sources.filter((source) => source.kind === 'screen')
|
||||
const windows = request.sources.filter((source) => source.kind === 'window')
|
||||
const selected = request.sources.find((source) => source.id === selectedId) ?? null
|
||||
const audioAvailable = selected?.kind === 'screen'
|
||||
|
||||
const renderSources = (sources: DisplayMediaSource[]) => (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(source.id)}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-1.5 rounded-md border p-1.5 text-left transition-colors',
|
||||
source.id === selectedId
|
||||
? 'border-ring bg-accent'
|
||||
: 'border-border hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-20 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
{source.thumbnailDataUrl ? (
|
||||
<img
|
||||
src={source.thumbnailDataUrl}
|
||||
alt=""
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{source.appIconDataUrl ? (
|
||||
<img src={source.appIconDataUrl} alt="" className="size-3.5 shrink-0" />
|
||||
) : null}
|
||||
<span className="truncate text-xs text-foreground">{source.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onCancel() }}>
|
||||
<DialogContent className="w-[min(36rem,calc(100%-2rem))] max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share your screen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose what to share with this site.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-[50vh] flex-col gap-3 overflow-y-auto pr-1">
|
||||
{screens.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Screens</span>
|
||||
{renderSources(screens)}
|
||||
</div>
|
||||
)}
|
||||
{windows.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Windows</span>
|
||||
{renderSources(windows)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="items-center sm:justify-between">
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
audioAvailable ? 'text-foreground' : 'text-muted-foreground/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={audioAvailable && shareAudio}
|
||||
disabled={!audioAvailable}
|
||||
onChange={(e) => setShareAudio(e.target.checked)}
|
||||
/>
|
||||
Also share system audio
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selected}
|
||||
onClick={() => {
|
||||
if (!selected) return
|
||||
onSubmit(selected.id, audioAvailable && shareAudio)
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
|
||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||
const [addressValue, setAddressValue] = useState('')
|
||||
const [authQueue, setAuthQueue] = useState<HttpAuthRequest[]>([])
|
||||
const [displayMediaQueue, setDisplayMediaQueue] = useState<DisplayMediaRequest[]>([])
|
||||
|
||||
const activeTabIdRef = useRef<string | null>(null)
|
||||
const addressFocusedRef = useRef(false)
|
||||
|
|
@ -249,6 +384,47 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
|
||||
const activeAuthRequest = authQueue[0] ?? null
|
||||
|
||||
// Same lifecycle as the auth queue: push on request, prune on main-side
|
||||
// resolution (timeout / window teardown), cancel leftovers on unmount so
|
||||
// the main-process callbacks and timers are freed immediately.
|
||||
const displayMediaQueueRef = useRef<DisplayMediaRequest[]>([])
|
||||
useEffect(() => {
|
||||
displayMediaQueueRef.current = displayMediaQueue
|
||||
}, [displayMediaQueue])
|
||||
|
||||
useEffect(() => {
|
||||
const offRequest = window.ipc.on('browser:displayMediaRequest', (incoming) => {
|
||||
setDisplayMediaQueue((queue) => [...queue, incoming as DisplayMediaRequest])
|
||||
})
|
||||
const offResolved = window.ipc.on('browser:displayMediaResolved', (incoming) => {
|
||||
const { requestId } = incoming as { requestId: string }
|
||||
setDisplayMediaQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
})
|
||||
return () => {
|
||||
offRequest()
|
||||
offResolved()
|
||||
for (const request of displayMediaQueueRef.current) {
|
||||
void window.ipc.invoke('browser:displayMediaResponse', { requestId: request.requestId })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const respondToDisplayMedia = useCallback(
|
||||
(requestId: string, choice: { sourceId: string; audio: boolean } | null) => {
|
||||
setDisplayMediaQueue((queue) => queue.filter((request) => request.requestId !== requestId))
|
||||
// Omit sourceId to cancel; include it to share the chosen source.
|
||||
void window.ipc.invoke(
|
||||
'browser:displayMediaResponse',
|
||||
choice
|
||||
? { requestId, sourceId: choice.sourceId, audio: choice.audio }
|
||||
: { requestId },
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const activeDisplayMediaRequest = displayMediaQueue[0] ?? null
|
||||
|
||||
const setViewVisible = useCallback((visible: boolean) => {
|
||||
if (viewVisibleRef.current === visible) return
|
||||
viewVisibleRef.current = visible
|
||||
|
|
@ -533,6 +709,11 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
autoCapitalize="off"
|
||||
/>
|
||||
</form>
|
||||
<browser-action-list
|
||||
partition="persist:rowboat-browser"
|
||||
alignment="bottom right"
|
||||
className="ml-1 flex shrink-0 items-center"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
|
@ -559,6 +740,17 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps)
|
|||
onCancel={() => respondToAuth(activeAuthRequest.requestId, null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!activeAuthRequest && activeDisplayMediaRequest && (
|
||||
<BrowserScreenSharePickerDialog
|
||||
key={activeDisplayMediaRequest.requestId}
|
||||
request={activeDisplayMediaRequest}
|
||||
onSubmit={(sourceId, audio) =>
|
||||
respondToDisplayMedia(activeDisplayMediaRequest.requestId, { sourceId, audio })
|
||||
}
|
||||
onCancel={() => respondToDisplayMedia(activeDisplayMediaRequest.requestId, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
52
apps/x/apps/renderer/src/components/chart-renderer.test.ts
Normal file
52
apps/x/apps/renderer/src/components/chart-renderer.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parseChartSource } from './chart-renderer'
|
||||
|
||||
const valid = {
|
||||
chart: 'line',
|
||||
x: 'day',
|
||||
y: 'pct',
|
||||
data: [{ day: 'Mon', pct: 0.1 }],
|
||||
}
|
||||
|
||||
describe('parseChartSource', () => {
|
||||
it('parses a valid config', () => {
|
||||
const { config, invalid } = parseChartSource(JSON.stringify(valid))
|
||||
expect(invalid).toBe(false)
|
||||
expect(config?.chart).toBe('line')
|
||||
})
|
||||
|
||||
it('treats incomplete JSON as streaming, not invalid', () => {
|
||||
const partial = JSON.stringify(valid).slice(0, 25)
|
||||
expect(parseChartSource(partial)).toEqual({ config: null, invalid: false })
|
||||
})
|
||||
|
||||
it('flags complete-but-schema-invalid JSON as invalid', () => {
|
||||
const { config, invalid } = parseChartSource(
|
||||
JSON.stringify({ chart: 'line', data: [] }),
|
||||
)
|
||||
expect(config).toBeNull()
|
||||
expect(invalid).toBe(true)
|
||||
})
|
||||
|
||||
it('maps the label/value pie aliases to x/y', () => {
|
||||
// Seen live: models emit pie configs with label/value field names.
|
||||
const { config, invalid } = parseChartSource(
|
||||
JSON.stringify({
|
||||
chart: 'pie',
|
||||
label: 'index',
|
||||
value: 'drop',
|
||||
data: [{ index: 'Nasdaq', drop: 2.4 }],
|
||||
}),
|
||||
)
|
||||
expect(invalid).toBe(false)
|
||||
expect(config?.x).toBe('index')
|
||||
expect(config?.y).toBe('drop')
|
||||
})
|
||||
|
||||
it('accepts a y array for multi-series charts', () => {
|
||||
const { config } = parseChartSource(
|
||||
JSON.stringify({ ...valid, y: ['a', 'b'] }),
|
||||
)
|
||||
expect(config?.y).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
178
apps/x/apps/renderer/src/components/chart-renderer.tsx
Normal file
178
apps/x/apps/renderer/src/components/chart-renderer.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useMemo } from 'react'
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import {
|
||||
LineChart, Line,
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts'
|
||||
|
||||
// Categorical palettes validated for CVD separation and surface contrast on
|
||||
// each mode's chart surface (dataviz six-checks validator). Slots are
|
||||
// assigned to series in fixed order — the dark column is the same hues
|
||||
// re-stepped for the dark surface, not a different palette.
|
||||
const SERIES_COLORS_LIGHT = ['#2a78d6', '#008300', '#e87ba4', '#eda100', '#1baf7a', '#eb6834']
|
||||
const SERIES_COLORS_DARK = ['#3987e5', '#008300', '#d55181', '#c98500', '#199e70', '#d95926']
|
||||
|
||||
interface ChartRendererProps {
|
||||
/** Raw contents of a ```chart fence: ChartBlockSchema JSON. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a chart fence body. Distinguishes the three states the renderer
|
||||
* shows: `streaming` (JSON incomplete — the model is still writing),
|
||||
* `invalid` (complete JSON that fails the schema), and a parsed config.
|
||||
* Models occasionally reach for pie-flavored field names despite the
|
||||
* skill's schema; the predictable label/value aliases map to x/y.
|
||||
*/
|
||||
export function parseChartSource(
|
||||
source: string,
|
||||
): { config: blocks.ChartBlock | null; invalid: boolean } {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(source)
|
||||
} catch {
|
||||
return { config: null, invalid: false }
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label
|
||||
if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value
|
||||
}
|
||||
const result = blocks.ChartBlockSchema.safeParse(parsed)
|
||||
return result.success
|
||||
? { config: result.data, invalid: false }
|
||||
: { config: null, invalid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline chart for chat messages: renders a ```chart fenced block (same
|
||||
* ChartBlockSchema the notes chart block uses) via recharts. Invalid or
|
||||
* still-streaming JSON renders as a quiet placeholder rather than an error —
|
||||
* the fence body arrives token by token while the model writes it.
|
||||
*/
|
||||
export function ChartRenderer({ source }: ChartRendererProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const colors = resolvedTheme === 'dark' ? SERIES_COLORS_DARK : SERIES_COLORS_LIGHT
|
||||
const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0'
|
||||
const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e'
|
||||
|
||||
const { config, invalid } = useMemo(() => parseChartSource(source), [source])
|
||||
|
||||
// Complete JSON that fails the schema is a real error — say so instead of
|
||||
// showing the streaming placeholder forever.
|
||||
if (invalid || (config && (!config.data || config.data.length === 0))) {
|
||||
return (
|
||||
<div className="my-2 flex h-24 items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{invalid ? 'Chart config invalid — ask me to redraw it' : 'Chart has no data'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="my-2 flex h-24 items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<BarChart3 className="size-3.5" />
|
||||
Preparing chart…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = config.data ?? []
|
||||
const series = blocks.chartSeries(config)
|
||||
const axisProps = {
|
||||
stroke: textColor,
|
||||
tick: { fill: textColor, fontSize: 11 },
|
||||
tickLine: false,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-md border border-border bg-card p-3">
|
||||
{config.title && (
|
||||
<div className="mb-2 text-sm font-medium text-foreground">{config.title}</div>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
{config.chart === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
|
||||
<XAxis dataKey={config.x} {...axisProps} />
|
||||
<YAxis {...axisProps} width={44} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
|
||||
border: `1px solid ${gridStroke}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((key, i) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[i % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2.5 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : config.chart === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
|
||||
<XAxis dataKey={config.x} {...axisProps} />
|
||||
<YAxis {...axisProps} width={44} />
|
||||
<Tooltip
|
||||
cursor={{ fill: resolvedTheme === 'dark' ? '#ffffff14' : '#00000009' }}
|
||||
contentStyle={{
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
|
||||
border: `1px solid ${gridStroke}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((key, i) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={colors[i % colors.length]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
|
||||
border: `1px solid ${gridStroke}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={series[0]}
|
||||
nameKey={config.x}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={90}
|
||||
stroke={resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb'}
|
||||
strokeWidth={2}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={colors[i % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ArrowUp,
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
Globe,
|
||||
ImagePlus,
|
||||
LoaderIcon,
|
||||
Brain,
|
||||
Lock,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
|
|
@ -38,15 +37,13 @@ import {
|
|||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
|
||||
import { ModelSelector, type ModelRef, type ReasoningEffortLevel } from '@/components/model-selector'
|
||||
import { useModels } from '@/hooks/use-models'
|
||||
import {
|
||||
type AttachmentIconKind,
|
||||
getAttachmentDisplayName,
|
||||
|
|
@ -85,128 +82,18 @@ const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
|
|||
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
|
||||
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
|
||||
interface ConfiguredModel {
|
||||
provider: ProviderName
|
||||
model: string
|
||||
}
|
||||
|
||||
// One picker group per connected provider. Catalog groups carry a resolved
|
||||
// model list (models:list / saved config); live groups carry credentials and
|
||||
// fetch their list from the provider inside the dropdown via
|
||||
// useProviderModels (models:listForProvider).
|
||||
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
|
||||
// Catalog-preferred flavors that degrade to a live fetch when models:list has
|
||||
// no catalog for them (signed-in mode returns only the rowboat provider, or
|
||||
// the models.dev cache is empty).
|
||||
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
|
||||
|
||||
type ModelPickerGroup =
|
||||
| { kind: 'catalog'; flavor: string; models: string[] }
|
||||
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
|
||||
|
||||
// Rendered inside the dropdown's radio group: each live provider fetches its
|
||||
// own list, so groups load and fail independently. Pinned models (the saved
|
||||
// default / app default) render first — the model that actually runs is
|
||||
// always pickable even while the fetch is pending or failed. Live-fetched
|
||||
// ids carry no reasoning metadata, so the effort control stays hidden for
|
||||
// them (reasoningByKey lookup misses default to off).
|
||||
//
|
||||
// The group owns its header so it can hide itself when the search filter
|
||||
// matches none of its rows. Loading/error rows are status, not models — they
|
||||
// render (with the header) regardless of the filter, and don't count toward
|
||||
// the parent's "No models match" check (which is what gets reported up).
|
||||
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
|
||||
group: Extract<ModelPickerGroup, { kind: 'live' }>
|
||||
label: string
|
||||
pinnedModels: string[]
|
||||
filter: string
|
||||
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
|
||||
}) {
|
||||
const { status, models, error, refetch } = useProviderModels({
|
||||
flavor: group.flavor,
|
||||
apiKey: group.apiKey,
|
||||
baseURL: group.baseURL,
|
||||
})
|
||||
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
|
||||
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
|
||||
const showStatus = status === 'loading' || status === 'error'
|
||||
const hasModelRows = visible.length > 0
|
||||
useEffect(() => {
|
||||
onModelRowsChange(group.flavor, hasModelRows)
|
||||
}, [group.flavor, hasModelRows, onModelRowsChange])
|
||||
if (!hasModelRows && !showStatus) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
{visible.map((m) => {
|
||||
const key = `${group.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
{status === 'loading' && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<LoaderIcon className="h-3 w-3 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
refetch()
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type RecentWorkDir = {
|
||||
path: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface SelectedModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
|
||||
|
||||
// '' = auto (provider default). Ordered as shown in the picker.
|
||||
const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [
|
||||
{ value: '', label: 'Auto', hint: 'Provider default' },
|
||||
{ value: 'low', label: 'Fast', hint: 'Minimal thinking' },
|
||||
{ value: 'medium', label: 'Balanced', hint: 'Moderate thinking' },
|
||||
{ value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' },
|
||||
]
|
||||
// The picker itself lives in ModelSelector; these aliases keep the composer's
|
||||
// public prop surface stable for existing consumers (chat-sidebar, App).
|
||||
export type SelectedModel = ModelRef
|
||||
export type { ReasoningEffortLevel } from '@/components/model-selector'
|
||||
|
||||
export type PermissionMode = 'manual' | 'auto'
|
||||
|
||||
function getSelectedModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
switch (kind) {
|
||||
case 'audio':
|
||||
|
|
@ -398,21 +285,15 @@ function ChatInputInner({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
|
||||
|
||||
const [modelGroups, setModelGroups] = useState<ModelPickerGroup[]>([])
|
||||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
// The effective runtime default (what a run actually uses when the user
|
||||
// hasn't picked a model) — shown in the picker instead of guessing from
|
||||
// list order, which can disagree with the real default.
|
||||
const [defaultModel, setDefaultModel] = useState<ConfiguredModel | null>(null)
|
||||
const loadModelConfigEpoch = useRef(0)
|
||||
// Shared model-catalog store (one fetch app-wide); sign-in state also
|
||||
// gates search availability below.
|
||||
const { isRowboatConnected, refresh: refreshModels } = useModels()
|
||||
const [selectedModel, setSelectedModel] = useState<SelectedModel | null>(null)
|
||||
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
|
||||
// '' = auto. Per-model reasoning capability ("provider/model" → flag) from
|
||||
// models:list; the effort control renders only for known-reasoning models.
|
||||
// '' = auto. Effort is per-turn config: reported up, never persisted.
|
||||
const [reasoningEffort, setReasoningEffort] = useState<'' | ReasoningEffortLevel>('')
|
||||
const [reasoningByKey, setReasoningByKey] = useState<Record<string, boolean>>({})
|
||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||
|
|
@ -452,7 +333,7 @@ function ChatInputInner({
|
|||
// no-dep effect below still re-collapses if any toggle happens to widen the row.
|
||||
useLayoutEffect(() => {
|
||||
setCollapseLevel(0)
|
||||
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey])
|
||||
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, selectedModel])
|
||||
|
||||
// After each render, if the left group still overflows, collapse one more step.
|
||||
// Runs before paint, so the intermediate (overflowing) state is never visible.
|
||||
|
|
@ -484,143 +365,17 @@ function ChatInputInner({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Check Rowboat sign-in state
|
||||
// The store loads on mount and re-fetches on config/sign-in events by
|
||||
// itself; re-fetch on tab activation too, preserving the old per-mount
|
||||
// reload that picked up external edits to config/models.json.
|
||||
const didLoadModelsRef = useRef(false)
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
|
||||
}).catch(() => setIsRowboatConnected(false))
|
||||
}, [isActive])
|
||||
|
||||
// Update sign-in state when OAuth events fire
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', () => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
|
||||
}).catch(() => setIsRowboatConnected(false))
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Load the list of models the user can choose from. Hybrid mode: signed-in
|
||||
// users get the gateway list AND every BYOK provider configured in
|
||||
// models.json (selecting a BYOK model routes that message through the
|
||||
// user's own key / local server). Signed-out users get BYOK only.
|
||||
const loadModelConfig = useCallback(async () => {
|
||||
// Concurrent runs race (mount fires one before the sign-in state resolves,
|
||||
// which fires another) — only the newest run may write state, else a slow
|
||||
// stale run can clobber the fresh list with an empty one.
|
||||
const epoch = ++loadModelConfigEpoch.current
|
||||
try {
|
||||
const def = await window.ipc.invoke('llm:getDefaultModel', null)
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setDefaultModel({ provider: def.provider as ProviderName, model: def.model })
|
||||
} catch {
|
||||
if (loadModelConfigEpoch.current === epoch) setDefaultModel(null)
|
||||
if (!didLoadModelsRef.current) {
|
||||
didLoadModelsRef.current = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const groups: ModelPickerGroup[] = []
|
||||
|
||||
// Full catalog per provider (gateway + models.dev cloud providers).
|
||||
const catalog: Record<string, string[]> = {}
|
||||
const reasoningFlags: Record<string, boolean> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
|
||||
for (const m of p.models || []) {
|
||||
if (typeof m.reasoning === 'boolean') {
|
||||
reasoningFlags[`${p.id}/${m.id}`] = m.reasoning
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* offline / no catalog — groups fall back to saved config below */ }
|
||||
if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags)
|
||||
|
||||
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// List the default provider's group first.
|
||||
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
|
||||
const flavors = Object.keys(parsed?.providers || {})
|
||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
|
||||
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
|
||||
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
|
||||
if (!apiKey && !baseURL) continue // provider not configured
|
||||
const savedModel = typeof e.model === 'string' ? e.model : ''
|
||||
|
||||
// Live flavors fetch their list from the provider inside the
|
||||
// dropdown, with the credentials saved in config. Catalog flavors
|
||||
// degrade to the same live fetch when models:list carried no
|
||||
// catalog for them (signed in, or empty models.dev cache).
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
|
||||
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
|
||||
continue
|
||||
}
|
||||
|
||||
// Catalog group: the saved default model leads, then the catalog.
|
||||
// Saved models[] survives as the fallback for unknown flavors the
|
||||
// live fetch doesn't support.
|
||||
const models: string[] = []
|
||||
const push = (model: string) => {
|
||||
if (model && !models.includes(model)) models.push(model)
|
||||
}
|
||||
push(savedModel)
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(m)
|
||||
} else {
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(m)
|
||||
}
|
||||
groups.push({ kind: 'catalog', flavor, models })
|
||||
}
|
||||
|
||||
// The user's explicit default selection leads the picker: its group
|
||||
// first and, within a catalog group, the model itself first. (Live
|
||||
// groups pin the default at the top themselves.)
|
||||
const sel = parsed?.defaultSelection
|
||||
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
|
||||
const index = groups.findIndex((g) => g.flavor === sel.provider)
|
||||
if (index >= 0) {
|
||||
const [group] = groups.splice(index, 1)
|
||||
groups.unshift(group)
|
||||
if (group.kind === 'catalog') {
|
||||
const mi = group.models.indexOf(sel.model)
|
||||
if (mi > 0) {
|
||||
group.models.splice(mi, 1)
|
||||
group.models.unshift(sel.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setModelGroups(groups)
|
||||
} catch (err) {
|
||||
// No config yet — but surface unexpected failures for diagnosis.
|
||||
console.error('[chat-input] failed to load model list', err)
|
||||
}
|
||||
}, [isRowboatConnected])
|
||||
|
||||
useEffect(() => {
|
||||
loadModelConfig()
|
||||
}, [isActive, loadModelConfig])
|
||||
|
||||
// Reload when model config changes (e.g. from settings dialog)
|
||||
useEffect(() => {
|
||||
const handler = () => { loadModelConfig() }
|
||||
window.addEventListener('models-config-changed', handler)
|
||||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
refreshModels()
|
||||
}, [isActive, refreshModels])
|
||||
|
||||
// Load the global code-mode feature flag (from settings) and stay in sync.
|
||||
useEffect(() => {
|
||||
|
|
@ -794,83 +549,31 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
// Search filter for the model dropdown. Reset each time the menu opens;
|
||||
// matching is a case-insensitive substring test on the model id. Live
|
||||
// groups filter themselves and report whether they still have rows, so the
|
||||
// parent can render the global "No models match" row.
|
||||
const [modelFilter, setModelFilter] = useState('')
|
||||
const modelFilterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
|
||||
const modelFilterValue = modelFilter.trim().toLowerCase()
|
||||
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
|
||||
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
|
||||
}, [])
|
||||
|
||||
// The effective default always renders even when no group carries it (the
|
||||
// gateway list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. Live groups
|
||||
// pin the default themselves, so a flavor match is enough there.
|
||||
const standaloneDefault = useMemo<ConfiguredModel | null>(() => {
|
||||
if (!defaultModel) return null
|
||||
const covered = modelGroups.some((g) =>
|
||||
g.flavor === defaultModel.provider &&
|
||||
(g.kind === 'live' || g.models.includes(defaultModel.model)))
|
||||
return covered ? null : defaultModel
|
||||
}, [modelGroups, defaultModel])
|
||||
|
||||
const standaloneVisible = standaloneDefault !== null &&
|
||||
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
|
||||
// Nothing matches anywhere → "No models match". Live groups that haven't
|
||||
// reported yet (first render after opening) count as having rows so the
|
||||
// empty row never flashes.
|
||||
const anyModelRowVisible = standaloneVisible || modelGroups.some((g) =>
|
||||
g.kind === 'catalog'
|
||||
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: liveGroupHasRows[g.flavor] !== false)
|
||||
|
||||
// Selecting a model affects the *next* run created from this tab (frozen
|
||||
// once a run exists) AND persists as the app default so background agents
|
||||
// and new tabs follow the last pick. The models-config-changed dispatch
|
||||
// re-runs loadModelConfig here too — that re-read lands on the same
|
||||
// selection (activeModelKey is untouched, the live lists come from the
|
||||
// hook's cache), so it's visually a no-op.
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
// re-fetches the shared store too — that re-read lands on the same
|
||||
// selection (selectedModel is untouched, the live lists come from the
|
||||
// useProviderModels cache), so it's visually a no-op.
|
||||
const handleModelChange = useCallback((model: SelectedModel | null) => {
|
||||
if (lockedModel) return
|
||||
const slash = key.indexOf('/')
|
||||
if (slash <= 0 || slash === key.length - 1) return
|
||||
const provider = key.slice(0, slash)
|
||||
const model = key.slice(slash + 1)
|
||||
setActiveModelKey(key)
|
||||
onSelectedModelChange?.({ provider, model })
|
||||
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } })
|
||||
// null = the sentinel row, which the composer never renders (no
|
||||
// defaultOption) — guard for the widened onChange contract only.
|
||||
if (!model) return
|
||||
setSelectedModel(model)
|
||||
onSelectedModelChange?.(model)
|
||||
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } })
|
||||
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
|
||||
.catch(() => { toast.error('Failed to save default model') })
|
||||
}, [lockedModel, onSelectedModelChange])
|
||||
|
||||
// Reasoning effort applies to the model the next message will actually use:
|
||||
// the run's frozen model once one exists, else the picker selection, else
|
||||
// the app default. Only known-reasoning models show the control.
|
||||
const effectiveModelKey = lockedModel
|
||||
? `${lockedModel.provider}/${lockedModel.model}`
|
||||
: activeModelKey
|
||||
|| (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
const reasoningAvailable = reasoningByKey[effectiveModelKey] === true
|
||||
|
||||
const handleReasoningEffortChange = useCallback((value: string) => {
|
||||
const effort = value === 'low' || value === 'medium' || value === 'high' ? value : ''
|
||||
// Effort is per-turn and unpersisted; ModelSelector reports '' when the
|
||||
// effective model loses reasoning support so a stale effort never sticks.
|
||||
const handleReasoningEffortChange = useCallback((effort: '' | ReasoningEffortLevel) => {
|
||||
setReasoningEffort(effort)
|
||||
onReasoningEffortChange?.(effort === '' ? null : effort)
|
||||
}, [onReasoningEffortChange])
|
||||
|
||||
// Switching to a model without reasoning support drops a stale selection —
|
||||
// otherwise the next message would carry an effort the model rejects.
|
||||
useEffect(() => {
|
||||
if (!reasoningAvailable && reasoningEffort !== '') {
|
||||
setReasoningEffort('')
|
||||
onReasoningEffortChange?.(null)
|
||||
}
|
||||
}, [reasoningAvailable, reasoningEffort, onReasoningEffortChange])
|
||||
|
||||
// Restore the tab draft when this input mounts.
|
||||
useEffect(() => {
|
||||
if (initialDraft) {
|
||||
|
|
@ -1462,165 +1165,13 @@ function ChatInputInner({
|
|||
</DropdownMenu>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{reasoningAvailable && (
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Brain className="h-3 w-3 shrink-0" />
|
||||
{reasoningEffort !== '' && (
|
||||
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === reasoningEffort)?.label}</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Reasoning effort — applies to your next message</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={reasoningEffort} onValueChange={handleReasoningEffortChange}>
|
||||
{REASONING_EFFORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
|
||||
<span>{option.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{lockedModel ? (
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// The filter is per-opening, never sticky. Focus the search
|
||||
// input once the content has mounted and Radix has run its own
|
||||
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
|
||||
if (open) {
|
||||
setModelFilter('')
|
||||
setLiveGroupHasRows({})
|
||||
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{getSelectedModelDisplayName(
|
||||
(activeModelKey ? activeModelKey.slice(activeModelKey.indexOf('/') + 1) : '')
|
||||
|| defaultModel?.model
|
||||
|| 'Model'
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0 overflow-hidden">
|
||||
{modelGroups.length === 0 && !standaloneDefault ? (
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fixed search header — lives OUTSIDE the scroll area (the
|
||||
inner div below scrolls), so it's flush at the very top
|
||||
and always visible without any scroll. */}
|
||||
<div className="bg-popover p-1">
|
||||
<input
|
||||
ref={modelFilterInputRef}
|
||||
value={modelFilter}
|
||||
onChange={(e) => setModelFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Printable keys belong to the input, not the menu's
|
||||
// typeahead; arrows and Escape stay with the menu.
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
placeholder="Search models…"
|
||||
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-1 pt-0">
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{standaloneDefault && standaloneVisible && (
|
||||
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
|
||||
<span className="truncate">{standaloneDefault.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{modelGroups.map((g) => {
|
||||
const label = providerDisplayNames[g.flavor] || g.flavor
|
||||
if (g.kind === 'live') {
|
||||
// The app default leads its live group; the group's
|
||||
// own saved model follows (both stay pickable through
|
||||
// fetch loading/failure).
|
||||
const pinned: string[] = []
|
||||
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
|
||||
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
|
||||
return (
|
||||
<LiveProviderGroupItems
|
||||
key={g.flavor}
|
||||
group={g}
|
||||
label={label}
|
||||
pinnedModels={pinned}
|
||||
filter={modelFilterValue}
|
||||
onModelRowsChange={handleLiveGroupRows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const visibleModels = modelFilterValue
|
||||
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: g.models
|
||||
if (visibleModels.length === 0) return null
|
||||
return (
|
||||
<Fragment key={g.flavor}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
{visibleModels.map((m) => {
|
||||
const key = `${g.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{modelFilterValue && !anyModelRowVisible && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<ModelSelector
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
lockedModel={lockedModel}
|
||||
effort={reasoningEffort}
|
||||
onEffortChange={handleReasoningEffortChange}
|
||||
/>
|
||||
{onStartCall && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,20 @@ export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
|
|||
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
|
||||
}
|
||||
|
||||
// Adapt an engine option list for ModelSelector: the 'default' entry becomes
|
||||
// the sentinel (keeping the engine's own label, e.g. Claude's "Default
|
||||
// (recommended)"), the rest become staticOptions rows.
|
||||
export function toSelectorOptions(options: CodeAgentOption[]): {
|
||||
defaultLabel: string
|
||||
options: Array<{ id: string; label?: string }>
|
||||
} {
|
||||
const all = withDefault(options)
|
||||
return {
|
||||
defaultLabel: all.find((o) => o.value === 'default')?.label ?? 'Default',
|
||||
options: all.filter((o) => o.value !== 'default').map((o) => ({ id: o.value, label: o.label })),
|
||||
}
|
||||
}
|
||||
|
||||
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
|
||||
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
|
||||
import { fetchCodeAgentOptions, toSelectorOptions, withDefault, optionLabel } from './code-agent-options'
|
||||
import { ModelSelector } from '@/components/model-selector'
|
||||
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -228,27 +229,15 @@ export function CodeView({
|
|||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Coding agent model"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
|
||||
{m.label}
|
||||
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ModelSelector
|
||||
triggerTitle="Coding agent model"
|
||||
defaultOption={{ label: toSelectorOptions(modelOpts.models).defaultLabel }}
|
||||
staticOptions={toSelectorOptions(modelOpts.models).options}
|
||||
value={selectedSession.agentModel && selectedSession.agentModel !== 'default'
|
||||
? { provider: '', model: selectedSession.agentModel }
|
||||
: null}
|
||||
onChange={(ref) => void handleUpdateSession({ agentModel: ref?.model ?? 'default' })}
|
||||
/>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
|
||||
import { fetchCodeAgentOptions, toSelectorOptions, withDefault } from './code-agent-options'
|
||||
import { ModelSelector, type ModelRef } from '@/components/model-selector'
|
||||
import { useModels } from '@/hooks/use-models'
|
||||
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -25,7 +27,6 @@ import {
|
|||
import type { ProjectRow } from './use-code-sessions'
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type ModelOption = { provider: string; model: string }
|
||||
|
||||
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask every time',
|
||||
|
|
@ -33,38 +34,6 @@ const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
|||
yolo: 'Auto-approve everything (YOLO)',
|
||||
}
|
||||
|
||||
// Models the user can pick for Rowboat-mode turns — mirrors the chat
|
||||
// composer's loading: gateway list when signed in, models.json otherwise.
|
||||
async function loadModelOptions(): Promise<ModelOption[]> {
|
||||
try {
|
||||
const oauth = await window.ipc.invoke('oauth:getState', null)
|
||||
const connected = oauth.config?.rowboat?.connected ?? false
|
||||
if (connected) {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined)
|
||||
?.find((p) => p.id === 'rowboat')
|
||||
return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id }))
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ModelOption[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) models.push({ provider: flavor, model })
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function NewSessionDialog({
|
||||
projectRow,
|
||||
open,
|
||||
|
|
@ -84,9 +53,11 @@ export function NewSessionDialog({
|
|||
const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo')
|
||||
const [title, setTitle] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
|
||||
// 'default' = let the backend use the configured default model.
|
||||
const [modelKey, setModelKey] = useState('default')
|
||||
// null = let the backend use the configured default model.
|
||||
const [sessionModel, setSessionModel] = useState<ModelRef | null>(null)
|
||||
// Gates the Rowboat-mode picker the way the old options list did: no
|
||||
// configured providers → no picker.
|
||||
const { groups: modelGroups } = useModels()
|
||||
// The coding agent's own model + reasoning effort. 'default' leaves the
|
||||
// engine default. Choices are discovered live per agent (see effect below).
|
||||
const [agentModel, setAgentModel] = useState('default')
|
||||
|
|
@ -102,10 +73,9 @@ export function NewSessionDialog({
|
|||
setCreating(false)
|
||||
setIsolation('in-repo')
|
||||
setMode('direct')
|
||||
setModelKey('default')
|
||||
setSessionModel(null)
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
void loadModelOptions().then(setModelOptions)
|
||||
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
|
||||
setAgentStatus(status)
|
||||
// Default to whichever agent is actually ready.
|
||||
|
|
@ -138,9 +108,6 @@ export function NewSessionDialog({
|
|||
if (!projectRow) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const picked = modelKey !== 'default'
|
||||
? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey)
|
||||
: undefined
|
||||
const res = await window.ipc.invoke('codeSession:create', {
|
||||
projectId: projectRow.project.id,
|
||||
title: title.trim() || undefined,
|
||||
|
|
@ -148,7 +115,7 @@ export function NewSessionDialog({
|
|||
mode,
|
||||
policy,
|
||||
isolation,
|
||||
...(picked ? { model: picked.model, provider: picked.provider } : {}),
|
||||
...(sessionModel ? { model: sessionModel.model, provider: sessionModel.provider } : {}),
|
||||
...(agentModel !== 'default' ? { agentModel } : {}),
|
||||
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
|
||||
})
|
||||
|
|
@ -303,20 +270,19 @@ export function NewSessionDialog({
|
|||
{/* The coding agent's own model + reasoning effort, discovered live
|
||||
from the engine and applied to the ACP session each turn (so they
|
||||
stay editable from the session header later). Effort is a separate
|
||||
axis only for Claude; Codex folds it into the model id. */}
|
||||
axis only for Claude; Codex folds it into the model id — it stays
|
||||
a plain Select deliberately: it's not model selection, so it's
|
||||
out of ModelSelector's scope. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={agentModel} onValueChange={setAgentModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
defaultOption={{ label: toSelectorOptions(modelOpts.models).defaultLabel }}
|
||||
staticOptions={toSelectorOptions(modelOpts.models).options}
|
||||
value={agentModel !== 'default' ? { provider: '', model: agentModel } : null}
|
||||
onChange={(ref) => setAgentModel(ref?.model ?? 'default')}
|
||||
/>
|
||||
</div>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
@ -337,21 +303,15 @@ export function NewSessionDialog({
|
|||
|
||||
{/* The model only powers Rowboat's own turns; the coding agent uses its
|
||||
own configured model, so hide this entirely for direct sessions. */}
|
||||
{mode === 'rowboat' && modelOptions.length > 0 && (
|
||||
{mode === 'rowboat' && modelGroups.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={modelKey} onValueChange={setModelKey}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default model</SelectItem>
|
||||
{modelOptions.map((m) => {
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return <SelectItem key={key} value={key}>{m.model}</SelectItem>
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
defaultOption={{ label: 'Default model' }}
|
||||
value={sessionModel}
|
||||
onChange={setSessionModel}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Used when Rowboat drives. Fixed once the session is created, like any chat.
|
||||
</p>
|
||||
|
|
|
|||
109
apps/x/apps/renderer/src/components/credit-celebration.tsx
Normal file
109
apps/x/apps/renderer/src/components/credit-celebration.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { formatCreditsAsDollars, type CreditActivatedEvent } from "@x/shared/dist/credits.js"
|
||||
|
||||
// Deterministic pseudo-random confetti geometry — stable per piece index so a
|
||||
// re-render mid-animation doesn't reshuffle the burst.
|
||||
function confettiPieces(seed: number) {
|
||||
const COLORS = ["#f59e0b", "#10b981", "#3b82f6", "#ec4899", "#8b5cf6", "#ef4444"]
|
||||
return Array.from({ length: 28 }, (_, i) => {
|
||||
const t = Math.sin(seed * 997 + i * 131) * 0.5 + 0.5
|
||||
const u = Math.sin(seed * 613 + i * 379) * 0.5 + 0.5
|
||||
const angle = (i / 28) * Math.PI * 2
|
||||
const distance = 90 + t * 140
|
||||
return {
|
||||
id: i,
|
||||
x: Math.cos(angle) * distance,
|
||||
y: Math.sin(angle) * distance * 0.75 - 40 - u * 60,
|
||||
rotate: (t - 0.5) * 720,
|
||||
color: COLORS[i % COLORS.length],
|
||||
size: 5 + u * 5,
|
||||
round: i % 3 === 0,
|
||||
delay: t * 0.12,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-window celebration shown when the backend confirms a first-time-action
|
||||
* credit grant (`credits:didActivate`): a confetti burst plus a card naming
|
||||
* the action and the amount earned. Mount once near the app root.
|
||||
*/
|
||||
export function CreditCelebration() {
|
||||
const [event, setEvent] = useState<CreditActivatedEvent | null>(null)
|
||||
const [burst, setBurst] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
return window.ipc.on("credits:didActivate", (e) => {
|
||||
setEvent(e)
|
||||
setBurst((n) => n + 1)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) return
|
||||
const timer = window.setTimeout(() => setEvent(null), 4500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [event])
|
||||
|
||||
const pieces = useMemo(() => confettiPieces(burst), [burst])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{event && (
|
||||
<div className="pointer-events-none fixed inset-0 z-[200] flex items-center justify-center">
|
||||
{/* confetti burst */}
|
||||
<div className="absolute left-1/2 top-1/2">
|
||||
{pieces.map((p) => (
|
||||
<motion.span
|
||||
key={`${burst}-${p.id}`}
|
||||
className="absolute block"
|
||||
style={{
|
||||
width: p.size,
|
||||
height: p.size * (p.round ? 1 : 0.6),
|
||||
backgroundColor: p.color,
|
||||
borderRadius: p.round ? "9999px" : "1px",
|
||||
}}
|
||||
initial={{ x: 0, y: 0, opacity: 1, rotate: 0, scale: 0.6 }}
|
||||
animate={{
|
||||
x: p.x,
|
||||
y: [0, p.y, p.y + 260],
|
||||
opacity: [1, 1, 0],
|
||||
rotate: p.rotate,
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 2.2, delay: p.delay, ease: "easeOut", times: [0, 0.35, 1] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* reward card */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, y: 24, opacity: 0 }}
|
||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.9, y: -12, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 22 }}
|
||||
className="relative flex items-center gap-3 rounded-2xl border bg-background/95 px-5 py-4 shadow-2xl backdrop-blur"
|
||||
>
|
||||
<motion.div
|
||||
className="flex size-10 items-center justify-center rounded-full bg-amber-500/15"
|
||||
animate={{ rotate: [0, -12, 12, 0] }}
|
||||
transition={{ duration: 0.9, delay: 0.15 }}
|
||||
>
|
||||
<Sparkles className="size-5 text-amber-500" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCreditsAsDollars(event.credits)} in credits earned!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{event.title}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
90
apps/x/apps/renderer/src/components/invite-code-claim.tsx
Normal file
90
apps/x/apps/renderer/src/components/invite-code-claim.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { CheckCircle2, Loader2, Ticket } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCreditsState } from "@/hooks/use-credits-state"
|
||||
import { formatCreditsAsDollars } from "@x/shared/dist/credits.js"
|
||||
|
||||
/**
|
||||
* "Have an invite code?" entry: redeem another user's referral code — both
|
||||
* sides earn credits (the backend enforces one lifetime claim per account,
|
||||
* new accounts only). Self-gating: renders nothing unless the credit-rewards
|
||||
* feature is on, the user is eligible (signed-in, free tier), and this
|
||||
* account hasn't already redeemed a code. Shown in onboarding and in
|
||||
* settings → Account.
|
||||
*/
|
||||
export function InviteCodeClaim() {
|
||||
const state = useCreditsState()
|
||||
const [code, setCode] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [granted, setGranted] = useState<number | null>(null)
|
||||
|
||||
// referral state doubles as the claimed-by-me source; without it we can't
|
||||
// tell whether the entry still applies, so stay hidden
|
||||
if (!state || !state.enabled || !state.eligible || !state.referral) return null
|
||||
if (state.referral.claimedByMe && granted === null) return null
|
||||
|
||||
const submit = async () => {
|
||||
if (!code.trim() || submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke("referral:claim", { code })
|
||||
if (result.ok) {
|
||||
setGranted(result.creditsGranted)
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to claim invite code:", err)
|
||||
setError("Could not apply the invite code. Please try again.")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (granted !== null) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-emerald-500/5 px-4 py-3">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||
<p className="text-sm">
|
||||
Invite code applied — {formatCreditsAsDollars(granted)} in credits added to your account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Ticket className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Have an invite code?</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCode(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit()
|
||||
}}
|
||||
placeholder="ABC-DEF-GHJ"
|
||||
className="h-8 font-mono text-sm uppercase"
|
||||
maxLength={16}
|
||||
/>
|
||||
<Button size="sm" onClick={submit} disabled={!code.trim() || submitting}>
|
||||
{submitting ? <Loader2 className="size-4 animate-spin" /> : "Apply"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Enter a friend's code and you both earn {formatCreditsAsDollars(state.referral.refereeCredits)} in credits.
|
||||
</p>
|
||||
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector'
|
||||
import {
|
||||
Play, Square, Loader2, Sparkles,
|
||||
AlertCircle, Plus, X, Check, Pencil, Radio, Repeat, Clock, Zap,
|
||||
|
|
@ -605,19 +606,13 @@ function DetailsTab({
|
|||
{showAdvanced && (
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-[74px_1fr] gap-x-3 gap-y-2.5 text-xs">
|
||||
<span className="pt-1.5 text-muted-foreground">Model</span>
|
||||
<Input
|
||||
value={draft.model ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, model: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
<span className="pt-1.5 text-muted-foreground">Provider</span>
|
||||
<Input
|
||||
value={draft.provider ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, provider: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
<span className="pt-2 text-muted-foreground">Model</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
value={modelOverrideToRef(draft.model, draft.provider)}
|
||||
onChange={(ref) => setDraft({ ...draft, ...refToModelOverride(ref) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
|
|
|
|||
215
apps/x/apps/renderer/src/components/model-selector.test.tsx
Normal file
215
apps/x/apps/renderer/src/components/model-selector.test.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { __resetModelsForTests } from '@/hooks/use-models'
|
||||
import { ModelSelector } from './model-selector'
|
||||
|
||||
// Radix popper content needs these in jsdom.
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
|
||||
// Same preload stub pattern as use-models.test.tsx: invoke routes by channel.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
function serveTwoProviders(): void {
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] },
|
||||
{ id: 'anthropic', name: 'Anthropic', models: [{ id: 'claude-opus-4-8' }] },
|
||||
],
|
||||
})
|
||||
handlers['workspace:readFile'] = async () => ({
|
||||
data: JSON.stringify({
|
||||
provider: { flavor: 'openai' },
|
||||
model: 'gpt-5.4',
|
||||
providers: {
|
||||
openai: { apiKey: 'sk-a', model: 'gpt-5.4' },
|
||||
anthropic: { apiKey: 'sk-b', model: 'claude-opus-4-8' },
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function openMenu(): Promise<void> {
|
||||
const trigger = screen.getByRole('button')
|
||||
// Radix opens the menu from the trigger's keydown handler in jsdom
|
||||
// (pointerdown would ALSO toggle — one gesture only).
|
||||
fireEvent.keyDown(trigger, { key: 'Enter' })
|
||||
await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelsForTests()
|
||||
handlers = {}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ModelSelector', () => {
|
||||
it('renders the defaultOption label when value is null and round-trips null through onChange', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={{ provider: 'openai', model: 'gpt-5.4' }}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: 'Rowboat default' }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('gpt-5.4'))
|
||||
|
||||
await openMenu()
|
||||
const sentinel = await screen.findByText('Rowboat default')
|
||||
fireEvent.click(sentinel)
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('shows the sentinel as the trigger label when value is null', async () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
defaultOption={{ label: 'Same as assistant' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Same as assistant')
|
||||
})
|
||||
|
||||
it('providerFilter restricts the list to that provider', async () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
providerFilter="anthropic"
|
||||
defaultOption={{ label: 'Same as assistant' }}
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
})
|
||||
|
||||
it('inheritDefault renders its label muted in the trigger when value is null', () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
/>,
|
||||
)
|
||||
const label = screen.getByText('(global default)')
|
||||
expect(label.className).toContain('text-muted-foreground')
|
||||
})
|
||||
|
||||
it('un-scoped allowCustom splits "provider/model" on the first slash', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), {
|
||||
target: { value: 'openrouter/meituan/longcat-2.0' },
|
||||
})
|
||||
fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
|
||||
})
|
||||
|
||||
it('un-scoped allowCustom pairs slash-less text with the default provider', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
// Wait for the store snapshot (the default provider comes from it).
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-local-model' } })
|
||||
fireEvent.click(await screen.findByText('Use "my-local-model"'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
|
||||
})
|
||||
|
||||
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: 'Default (recommended)' }}
|
||||
staticOptions={[
|
||||
{ id: 'opus', label: 'Opus' },
|
||||
{ id: 'claude-opus-4-8', label: 'Opus' },
|
||||
{ id: 'sonnet', label: 'Sonnet' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
// Only the caller's rows — nothing from the shared catalog store.
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="menuitemradio"] span' })).not.toBeNull()
|
||||
// Colliding "Opus" labels are disambiguated by their raw id.
|
||||
expect(screen.getAllByText('Opus')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Sonnet'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' })
|
||||
|
||||
await openMenu()
|
||||
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' }))
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('allowCustom offers the typed id when nothing matches', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
providerFilter="anthropic"
|
||||
allowCustom
|
||||
defaultOption={{ label: 'Same as assistant' }}
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-custom-model' } })
|
||||
const custom = await screen.findByText('Use "my-custom-model"')
|
||||
fireEvent.click(custom)
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'my-custom-model' })
|
||||
})
|
||||
})
|
||||
549
apps/x/apps/renderer/src/components/model-selector.tsx
Normal file
549
apps/x/apps/renderer/src/components/model-selector.tsx
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Brain, ChevronDown, LoaderIcon } from 'lucide-react'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
|
||||
import { useProviderModels } from '@/hooks/use-provider-models'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { ModelRef } from '@/hooks/use-models'
|
||||
|
||||
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
|
||||
|
||||
const TOOLTIP_DELAY_MS = 1000
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
// Matches what other subscription clients call this provider; the auth
|
||||
// itself is "Sign in with ChatGPT" (Plus/Pro subscription).
|
||||
codex: 'OpenAI Codex',
|
||||
}
|
||||
|
||||
// '' = auto (provider default). Ordered as shown in the picker.
|
||||
const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [
|
||||
{ value: '', label: 'Auto', hint: 'Provider default' },
|
||||
{ value: 'low', label: 'Fast', hint: 'Minimal thinking' },
|
||||
{ value: 'medium', label: 'Balanced', hint: 'Moderate thinking' },
|
||||
{ value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' },
|
||||
]
|
||||
|
||||
function getModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
// Rendered inside the dropdown's radio group: each live provider fetches its
|
||||
// own list, so groups load and fail independently. Pinned models (the saved
|
||||
// default / app default) render first — the model that actually runs is
|
||||
// always pickable even while the fetch is pending or failed. Live-fetched
|
||||
// ids carry no reasoning metadata, so the effort control stays hidden for
|
||||
// them (reasoningByKey lookup misses default to off).
|
||||
//
|
||||
// The group owns its header so it can hide itself when the search filter
|
||||
// matches none of its rows. Loading/error rows are status, not models — they
|
||||
// render (with the header) regardless of the filter, and don't count toward
|
||||
// the parent's "No models match" check (which is what gets reported up).
|
||||
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
|
||||
group: Extract<ModelPickerGroup, { kind: 'live' }>
|
||||
label: string
|
||||
pinnedModels: string[]
|
||||
filter: string
|
||||
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
|
||||
}) {
|
||||
const { status, models, error, refetch } = useProviderModels({
|
||||
flavor: group.flavor,
|
||||
apiKey: group.apiKey,
|
||||
baseURL: group.baseURL,
|
||||
})
|
||||
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
|
||||
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
|
||||
const showStatus = status === 'loading' || status === 'error'
|
||||
const hasModelRows = visible.length > 0
|
||||
useEffect(() => {
|
||||
onModelRowsChange(group.flavor, hasModelRows)
|
||||
}, [group.flavor, hasModelRows, onModelRowsChange])
|
||||
if (!hasModelRows && !showStatus) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
{visible.map((m) => {
|
||||
const key = `${group.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
{status === 'loading' && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<LoaderIcon className="h-3 w-3 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
refetch()
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The standardized model picker (model-selection consolidation), mounted
|
||||
// everywhere models are chosen. One controlled value/onChange contract with
|
||||
// per-surface modes layered on as optional props: the composer's full
|
||||
// catalog picker (provider groups, live fetches, search, reasoning effort),
|
||||
// settings' default sentinel / field trigger / provider scoping / typed-id
|
||||
// escape hatch, per-task "(global default)" inheritance, and caller-supplied
|
||||
// restricted lists (coding-agent options).
|
||||
export interface ModelSelectorProps {
|
||||
/** Current selection; null follows the app default / the sentinel. */
|
||||
value: ModelRef | null
|
||||
/** null only ever fires when defaultOption is set (sentinel picked). */
|
||||
onChange: (value: ModelRef | null) => void
|
||||
/**
|
||||
* Pinned top entry ("Rowboat default", "Same as assistant") that selects
|
||||
* null. When set, a null value renders this label instead of the app
|
||||
* default model.
|
||||
*/
|
||||
defaultOption?: { label: string }
|
||||
/**
|
||||
* Inheritance flavor of defaultOption for per-task overrides
|
||||
* ("(global default)"): same sentinel row and null semantics, but null
|
||||
* means "inherit the global default at runtime" and the trigger renders
|
||||
* the label muted, so an un-overridden field reads like a placeholder.
|
||||
* Mutually exclusive with defaultOption (defaultOption wins).
|
||||
*/
|
||||
inheritDefault?: { label: string }
|
||||
/**
|
||||
* 'pill' is the composer's compact rounded trigger; 'field' is a
|
||||
* full-width bordered Select-style trigger for forms.
|
||||
*/
|
||||
variant?: 'pill' | 'field'
|
||||
/**
|
||||
* Restrict the picker to one provider's group (catalog or live). Providers
|
||||
* not configured in models.json fall back to their static models:list
|
||||
* catalog so a provider mid-setup still lists models.
|
||||
*/
|
||||
providerFilter?: string
|
||||
/**
|
||||
* When the search text matches no rows, offer a `Use "<text>"` row that
|
||||
* selects the typed id — arbitrary ids for ollama / openai-compatible.
|
||||
* With providerFilter the typed id attaches to that provider. Without it,
|
||||
* "provider/model" splits on the FIRST slash (so an OpenRouter id must be
|
||||
* typed provider-qualified: "openrouter/meituan/longcat-2.0"); text with
|
||||
* no slash attaches to the global default's provider.
|
||||
*/
|
||||
allowCustom?: boolean
|
||||
/**
|
||||
* Caller-supplied restricted list (e.g. a coding agent's own model
|
||||
* options): the picker renders ONLY these rows plus the defaultOption
|
||||
* sentinel — no catalog groups, no live fetches. Entries are opaque
|
||||
* engine ids, not provider/model pairs, so the selected ref is
|
||||
* {provider: '', model: id} — the id travels in .model and provider is
|
||||
* meaningless. Search filters on label and id; rows whose label differs
|
||||
* from their id show the id as secondary text (labels can collide, e.g.
|
||||
* Claude lists both the 'opus' alias and the concrete id as "Opus").
|
||||
*/
|
||||
staticOptions?: Array<{ id: string; label?: string }>
|
||||
/** Optional title attribute for the trigger button (header tooltips). */
|
||||
triggerTitle?: string
|
||||
/** Frozen selection: renders a static label + tooltip instead of the dropdown. */
|
||||
lockedModel?: ModelRef | null
|
||||
/**
|
||||
* Reasoning effort ('' = auto). The control renders only for models the
|
||||
* catalog flags as reasoning-capable. When the effective model loses
|
||||
* reasoning support, '' is reported up so a stale effort never outlives
|
||||
* its model. Omit onEffortChange to hide the effort control entirely.
|
||||
*/
|
||||
effort?: '' | ReasoningEffortLevel
|
||||
onEffortChange?: (effort: '' | ReasoningEffortLevel) => void
|
||||
}
|
||||
|
||||
// Radio value for the defaultOption sentinel row. Never a valid model key
|
||||
// (real keys always contain "provider/").
|
||||
const DEFAULT_OPTION_KEY = '__default__'
|
||||
|
||||
// Un-scoped custom entries can't know their provider, so the rule is:
|
||||
// scoped → the scoped provider; "provider/model" → split on the FIRST
|
||||
// slash; no slash → the global default's provider (matching how the
|
||||
// runtime pairs a provider-less model override).
|
||||
function parseCustomModel(text: string, providerFilter: string | undefined, defaultModel: ModelRef | null): ModelRef {
|
||||
if (providerFilter) return { provider: providerFilter, model: text }
|
||||
const slash = text.indexOf('/')
|
||||
if (slash > 0 && slash < text.length - 1) {
|
||||
return { provider: text.slice(0, slash), model: text.slice(slash + 1) }
|
||||
}
|
||||
return { provider: defaultModel?.provider ?? '', model: text }
|
||||
}
|
||||
|
||||
// Adapters for surfaces that persist a per-item override as two optional
|
||||
// strings (BackgroundTask.model/provider, LiveNote.model/provider) where
|
||||
// unset = inherit the global default. A model without a provider is legal
|
||||
// (the runtime pairs it with the default provider), so '' round-trips to
|
||||
// undefined and a null ref clears both fields.
|
||||
export function modelOverrideToRef(model: string | undefined, provider: string | undefined): ModelRef | null {
|
||||
return model ? { provider: provider ?? '', model } : null
|
||||
}
|
||||
|
||||
export function refToModelOverride(ref: ModelRef | null): { model: string | undefined; provider: string | undefined } {
|
||||
return { model: ref?.model || undefined, provider: ref?.provider || undefined }
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
value,
|
||||
onChange,
|
||||
defaultOption,
|
||||
inheritDefault,
|
||||
variant = 'pill',
|
||||
providerFilter,
|
||||
allowCustom = false,
|
||||
staticOptions,
|
||||
triggerTitle,
|
||||
lockedModel = null,
|
||||
effort = '',
|
||||
onEffortChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider } = useModels()
|
||||
|
||||
// inheritDefault is defaultOption with placeholder styling — one sentinel
|
||||
// code path, not two.
|
||||
const sentinel = defaultOption ?? inheritDefault
|
||||
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
|
||||
|
||||
const groups = useMemo<ModelPickerGroup[]>(() => {
|
||||
if (!providerFilter) return allGroups
|
||||
const scoped = allGroups.filter((g) => g.flavor === providerFilter)
|
||||
if (scoped.length > 0) return scoped
|
||||
const catalogModels = catalogByProvider[providerFilter] || []
|
||||
return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : []
|
||||
}, [allGroups, providerFilter, catalogByProvider])
|
||||
|
||||
// Search filter for the model dropdown. Reset each time the menu opens;
|
||||
// matching is a case-insensitive substring test on the model id. Live
|
||||
// groups filter themselves and report whether they still have rows, so the
|
||||
// parent can render the global "No models match" row.
|
||||
const [modelFilter, setModelFilter] = useState('')
|
||||
const modelFilterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
|
||||
const modelFilterValue = modelFilter.trim().toLowerCase()
|
||||
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
|
||||
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
|
||||
}, [])
|
||||
|
||||
// The effective default always renders even when no group carries it (the
|
||||
// gateway list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. Live groups
|
||||
// pin the default themselves, so a flavor match is enough there. A
|
||||
// provider-scoped picker only shows it when it belongs to that provider.
|
||||
const standaloneDefault = useMemo<ModelRef | null>(() => {
|
||||
if (!defaultModel) return null
|
||||
if (providerFilter && defaultModel.provider !== providerFilter) return null
|
||||
const covered = groups.some((g) =>
|
||||
g.flavor === defaultModel.provider &&
|
||||
(g.kind === 'live' || g.models.includes(defaultModel.model)))
|
||||
return covered ? null : defaultModel
|
||||
}, [groups, defaultModel, providerFilter])
|
||||
|
||||
const standaloneVisible = standaloneDefault !== null &&
|
||||
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
|
||||
// Static mode replaces all store-driven rows with the caller's list.
|
||||
const staticVisible = useMemo(() => {
|
||||
if (!staticOptions) return null
|
||||
if (!modelFilterValue) return staticOptions
|
||||
return staticOptions.filter((o) =>
|
||||
(o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue))
|
||||
}, [staticOptions, modelFilterValue])
|
||||
const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id
|
||||
// Nothing matches anywhere → "No models match". Live groups that haven't
|
||||
// reported yet (first render after opening) count as having rows so the
|
||||
// empty row never flashes.
|
||||
const anyModelRowVisible = staticVisible
|
||||
? staticVisible.length > 0
|
||||
: standaloneVisible || groups.some((g) =>
|
||||
g.kind === 'catalog'
|
||||
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: liveGroupHasRows[g.flavor] !== false)
|
||||
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
if (lockedModel) return
|
||||
if (key === DEFAULT_OPTION_KEY) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
// Static keys are opaque engine ids — no provider/model split (ids may
|
||||
// themselves contain slashes).
|
||||
if (staticOptions) {
|
||||
onChange({ provider: '', model: key })
|
||||
return
|
||||
}
|
||||
const slash = key.indexOf('/')
|
||||
if (slash <= 0 || slash === key.length - 1) return
|
||||
onChange({ provider: key.slice(0, slash), model: key.slice(slash + 1) })
|
||||
}, [lockedModel, onChange, staticOptions])
|
||||
|
||||
// Reasoning effort applies to the model the next message will actually use:
|
||||
// the frozen model when locked, else the picker selection, else the app
|
||||
// default. Only known-reasoning models show the control.
|
||||
const effectiveModelKey = lockedModel
|
||||
? `${lockedModel.provider}/${lockedModel.model}`
|
||||
: (value ? `${value.provider}/${value.model}` : '')
|
||||
|| (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
const reasoningAvailable = reasoningByKey[effectiveModelKey] === true
|
||||
|
||||
const handleEffortChange = useCallback((raw: string) => {
|
||||
onEffortChange?.(raw === 'low' || raw === 'medium' || raw === 'high' ? raw : '')
|
||||
}, [onEffortChange])
|
||||
|
||||
// Switching to a model without reasoning support drops a stale selection —
|
||||
// otherwise the next message would carry an effort the model rejects.
|
||||
useEffect(() => {
|
||||
if (!reasoningAvailable && effort !== '') {
|
||||
onEffortChange?.('')
|
||||
}
|
||||
}, [reasoningAvailable, effort, onEffortChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
{reasoningAvailable && onEffortChange && (
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Brain className="h-3 w-3 shrink-0" />
|
||||
{effort !== '' && (
|
||||
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === effort)?.label}</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Reasoning effort — applies to your next message</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={effort} onValueChange={handleEffortChange}>
|
||||
{REASONING_EFFORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
|
||||
<span>{option.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{lockedModel ? (
|
||||
<Tooltip delayDuration={TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{getModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// The filter is per-opening, never sticky. Focus the search
|
||||
// input once the content has mounted and Radix has run its own
|
||||
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
|
||||
if (open) {
|
||||
setModelFilter('')
|
||||
setLiveGroupHasRows({})
|
||||
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{variant === 'field' ? (
|
||||
// Styled after ui/select's SelectTrigger so it sits naturally
|
||||
// in forms next to real Select fields.
|
||||
<button
|
||||
type="button"
|
||||
title={triggerTitle}
|
||||
className="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:hover:bg-input/50"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn('truncate', !value && sentinelMuted && 'text-muted-foreground')}>
|
||||
{value
|
||||
? (staticOptions ? staticLabelFor(value.model) : value.model)
|
||||
: (sentinel?.label || defaultModel?.model || 'Select a model')}
|
||||
</span>
|
||||
{value && !providerFilter && !staticOptions && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[value.provider] || value.provider}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
title={triggerTitle}
|
||||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{staticOptions
|
||||
? (value ? staticLabelFor(value.model) : (sentinel?.label ?? 'Model'))
|
||||
: getModelDisplayName(value?.model || defaultModel?.model || 'Model')}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={variant === 'field' ? 'start' : 'end'}
|
||||
className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')}
|
||||
>
|
||||
{!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fixed search header — lives OUTSIDE the scroll area (the
|
||||
inner div below scrolls), so it's flush at the very top
|
||||
and always visible without any scroll. */}
|
||||
<div className="bg-popover p-1">
|
||||
<input
|
||||
ref={modelFilterInputRef}
|
||||
value={modelFilter}
|
||||
onChange={(e) => setModelFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Printable keys belong to the input, not the menu's
|
||||
// typeahead; arrows and Escape stay with the menu.
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
placeholder="Search models…"
|
||||
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-1 pt-0">
|
||||
<DropdownMenuRadioGroup
|
||||
value={
|
||||
value
|
||||
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
|
||||
: sentinel
|
||||
? DEFAULT_OPTION_KEY
|
||||
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{sentinel && (
|
||||
<DropdownMenuRadioItem value={DEFAULT_OPTION_KEY}>
|
||||
<span className="truncate">{sentinel.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{staticVisible?.map((o) => (
|
||||
<DropdownMenuRadioItem key={o.id} value={o.id}>
|
||||
<span className="truncate">{o.label ?? o.id}</span>
|
||||
{o.label && o.label !== o.id && (
|
||||
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
|
||||
)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{!staticOptions && standaloneDefault && standaloneVisible && (
|
||||
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
|
||||
<span className="truncate">{standaloneDefault.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{!staticOptions && groups.map((g) => {
|
||||
const label = providerDisplayNames[g.flavor] || g.flavor
|
||||
if (g.kind === 'live') {
|
||||
// The app default leads its live group; the group's
|
||||
// own saved model follows (both stay pickable through
|
||||
// fetch loading/failure).
|
||||
const pinned: string[] = []
|
||||
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
|
||||
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
|
||||
return (
|
||||
<LiveProviderGroupItems
|
||||
key={g.flavor}
|
||||
group={g}
|
||||
label={label}
|
||||
pinnedModels={pinned}
|
||||
filter={modelFilterValue}
|
||||
onModelRowsChange={handleLiveGroupRows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const visibleModels = modelFilterValue
|
||||
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: g.models
|
||||
if (visibleModels.length === 0) return null
|
||||
return (
|
||||
<Fragment key={g.flavor}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
{visibleModels.map((m) => {
|
||||
const key = `${g.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{modelFilterValue && !anyModelRowVisible && (
|
||||
allowCustom ? (
|
||||
// Escape hatch for ids the lists don't carry (local
|
||||
// servers, brand-new models): select exactly what was
|
||||
// typed. Scoped pickers attach it to their provider;
|
||||
// un-scoped ones split "provider/model" on the first
|
||||
// slash, else pair the text with the default provider.
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onChange(parseCustomModel(modelFilter.trim(), providerFilter, defaultModel))}
|
||||
>
|
||||
<span className="truncate">Use "{modelFilter.trim()}"</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, Minus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -29,10 +29,14 @@ import { AccountSettings } from "@/components/settings/account-settings"
|
|||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js"
|
||||
import type { ipc as ipcShared } from "@x/shared"
|
||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
import { useProviderModels } from "@/hooks/use-provider-models"
|
||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import { ModelSelector, type ModelRef } from "@/components/model-selector"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -107,6 +111,12 @@ const tabs: TabConfig[] = [
|
|||
path: "config/tags.json",
|
||||
description: "Configure tags for notes and emails",
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
label: "Advanced",
|
||||
icon: Wrench,
|
||||
description: "Advanced runtime and cost controls",
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help",
|
||||
|
|
@ -118,7 +128,7 @@ const tabs: TabConfig[] = [
|
|||
/** Sidebar nav grouping: identity first, capabilities, then app-level. */
|
||||
const NAV_SECTIONS: { label: string | null; ids: ConfigTab[] }[] = [
|
||||
{ label: null, ids: ["account", "connections", "mobile"] },
|
||||
{ label: "Configure", ids: ["models", "mcp", "security", "code-mode", "note-tagging"] },
|
||||
{ label: "Configure", ids: ["models", "mcp", "security", "code-mode", "note-tagging", "advanced"] },
|
||||
{ label: "App", ids: ["appearance", "notifications", "help"] },
|
||||
]
|
||||
|
||||
|
|
@ -132,11 +142,139 @@ interface SettingsDialogProps {
|
|||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
// --- Updates section (Help tab) ---
|
||||
|
||||
type UpdaterStatus = ipcShared.IPCChannels['updater:status']['req']
|
||||
|
||||
function UpdateSettings() {
|
||||
const [status, setStatus] = useState<UpdaterStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void window.ipc.invoke('updater:getStatus', null).then(setStatus)
|
||||
return window.ipc.on('updater:status', setStatus)
|
||||
}, [])
|
||||
|
||||
if (!status) return null
|
||||
|
||||
const checkNow = () => {
|
||||
// Progress arrives via updater:status pushes; using the invoke's snapshot
|
||||
// here could stomp a newer pushed state.
|
||||
void window.ipc.invoke('updater:check', null)
|
||||
}
|
||||
|
||||
let body: React.ReactNode
|
||||
switch (status.state) {
|
||||
case 'disabled':
|
||||
body = (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatic updates are disabled in development builds.
|
||||
</p>
|
||||
)
|
||||
break
|
||||
case 'unsupported':
|
||||
body = status.reason === 'not-in-applications' ? (
|
||||
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
||||
<AlertTriangle className="size-3.5 shrink-0 mt-0.5 text-amber-500" />
|
||||
Quit Rowboat and move it to the Applications folder to enable automatic updates.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"Automatic updates aren't available on this platform. "}
|
||||
<a
|
||||
href="https://github.com/rowboatlabs/rowboat/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Get the latest release
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
break
|
||||
case 'checking':
|
||||
case 'downloading':
|
||||
body = (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{status.state === 'checking' ? 'Checking for updates…' : 'Downloading update…'}
|
||||
</Button>
|
||||
)
|
||||
break
|
||||
case 'ready':
|
||||
body = (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.newVersion
|
||||
? `Rowboat ${status.newVersion} is ready to install.`
|
||||
: 'An update is ready to install.'}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => void window.ipc.invoke('updater:quitAndInstall', null)}
|
||||
>
|
||||
Restart to update
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
body = (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
||||
<AlertTriangle className="size-3.5 shrink-0 mt-0.5 text-destructive" />
|
||||
{`Update check failed: ${status.error ?? 'unknown error'}`}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={checkNow}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'idle':
|
||||
body = (
|
||||
<div className="space-y-2">
|
||||
{/* lastCheckedAt only exists after a check that found no update
|
||||
(an available update moves the state to downloading/ready), so
|
||||
idle + lastCheckedAt genuinely means "on the latest version". */}
|
||||
{status.lastCheckedAt !== undefined && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<span>
|
||||
{`You're up to date! Rowboat v${status.version} is the latest version.`}
|
||||
<span className="text-muted-foreground/60">
|
||||
{` Checked at ${new Date(status.lastCheckedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={checkNow}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for updates
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Updates</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Rowboat v{status.version}</p>
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Help & Support tab ---
|
||||
|
||||
function HelpSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<UpdateSettings />
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Help & Support</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
|
||||
|
|
@ -348,12 +486,6 @@ function AppearanceSettings() {
|
|||
|
||||
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
interface LlmModelOption {
|
||||
id: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
}
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
|
||||
|
|
@ -403,8 +535,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
})
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
|
@ -425,13 +555,14 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
apiKey: activeConfig.apiKey,
|
||||
baseURL: activeConfig.baseURL,
|
||||
})
|
||||
// "Sign in with ChatGPT" subscription state — only rendered on the OpenAI
|
||||
// card; independent of the API-key providerConfigs state above.
|
||||
const chatgpt = useChatGPT()
|
||||
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
|
||||
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
|
||||
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
|
||||
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
|
||||
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
|
||||
const modelsForProvider = modelsCatalog[provider] || []
|
||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||
|
||||
const primaryModel = activeConfig.models[0] || ""
|
||||
|
|
@ -571,29 +702,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load models catalog
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
setModelsLoading(true)
|
||||
const result = await window.ipc.invoke("models:list", null)
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
for (const p of result.providers || []) {
|
||||
catalog[p.id] = p.models || []
|
||||
}
|
||||
setModelsCatalog(catalog)
|
||||
} catch {
|
||||
setModelsCatalog({})
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
}, [dialogOpen])
|
||||
|
||||
// A saved openai-compatible model that the server's list doesn't confirm
|
||||
// (not listed, or /models unreachable) belongs in the visible Model field,
|
||||
// where it stays editable and wins over the silent pick.
|
||||
|
|
@ -952,6 +1060,53 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ChatGPT subscription — OAuth sign-in, independent of the API key and
|
||||
of models.json (the Codex model client consumes the token via core) */}
|
||||
{provider === "openai" && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
ChatGPT Subscription
|
||||
</span>
|
||||
{chatgpt.status.signedIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 min-w-0">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={chatgpt.signOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
) : chatgpt.isSigningIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Waiting for browser…
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Use your ChatGPT Plus/Pro subscription
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1045,144 +1200,33 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-function model overrides */}
|
||||
{/* Per-function model overrides. Persisted as bare model-id strings
|
||||
inside providers[flavor] ('' = "Same as assistant"), so the
|
||||
ModelRef picker value is adapted at this boundary: string ↔
|
||||
{provider, model} with the active card's flavor. allowCustom keeps
|
||||
arbitrary ids typeable (local servers, unlisted models). */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{!rowboatConnected && (<>
|
||||
{/* Knowledge graph model (right column) */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
{(
|
||||
[
|
||||
{ label: "Knowledge graph model", field: "knowledgeGraphModel" },
|
||||
{ label: "Meeting notes model", field: "meetingNotesModel" },
|
||||
{ label: "Track block model", field: "liveNoteAgentModel" },
|
||||
{ label: "Auto-permission model", field: "autoPermissionDecisionModel" },
|
||||
] as const
|
||||
).map(({ label, field }) => (
|
||||
<div key={field} className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
providerFilter={provider}
|
||||
allowCustom
|
||||
defaultOption={{ label: "Same as assistant" }}
|
||||
value={activeConfig[field] ? { provider, model: activeConfig[field] } : null}
|
||||
onChange={(ref) => updateConfig(provider, { [field]: ref ? ref.model : "" })}
|
||||
/>
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.knowledgeGraphModel}
|
||||
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meeting notes model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track block model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-permission model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.autoPermissionDecisionModel}
|
||||
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1547,44 +1591,19 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
|
|||
|
||||
// --- Rowboat Model Settings (when signed in via Rowboat) ---
|
||||
//
|
||||
// Hybrid mode: every dropdown lists the gateway catalog PLUS any models from
|
||||
// BYOK providers configured below. Values are provider-qualified
|
||||
// ("provider::model") and saved via models:updateConfig as {provider, model}
|
||||
// refs, so a signed-in user can e.g. keep the gateway assistant while
|
||||
// running background agents on a local Ollama model.
|
||||
|
||||
interface HybridModelOption {
|
||||
provider: string
|
||||
model: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
const HYBRID_SEP = "::"
|
||||
const hybridKey = (provider: string, model: string) => `${provider}${HYBRID_SEP}${model}`
|
||||
|
||||
function parseHybridKey(key: string): { provider: string; model: string } | null {
|
||||
const index = key.indexOf(HYBRID_SEP)
|
||||
if (index <= 0) return null
|
||||
return { provider: key.slice(0, index), model: key.slice(index + HYBRID_SEP.length) }
|
||||
}
|
||||
// Hybrid mode: every picker lists the gateway catalog PLUS any BYOK
|
||||
// providers configured below (ModelSelector renders the shared store's
|
||||
// groups, live-fetching list-less providers inside the open dropdown).
|
||||
// Saved via models:updateConfig as {provider, model} refs, so a signed-in
|
||||
// user can e.g. keep the gateway assistant while running background agents
|
||||
// on a local Ollama model. Selections stay local until Save — unlike the
|
||||
// composer, picking here must not write anything by itself.
|
||||
|
||||
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [options, setOptions] = useState<HybridModelOption[]>([])
|
||||
const [selectedDefault, setSelectedDefault] = useState("")
|
||||
const [selectedKg, setSelectedKg] = useState("")
|
||||
const [selectedLiveNote, setSelectedLiveNote] = useState("")
|
||||
const [selectedAutoPermission, setSelectedAutoPermission] = useState("")
|
||||
const [selectedDefault, setSelectedDefault] = useState<ModelRef | null>(null)
|
||||
const [selectedKg, setSelectedKg] = useState<ModelRef | null>(null)
|
||||
const [selectedLiveNote, setSelectedLiveNote] = useState<ModelRef | null>(null)
|
||||
const [selectedAutoPermission, setSelectedAutoPermission] = useState<ModelRef | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
|
|
@ -1594,63 +1613,29 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const collected: HybridModelOption[] = []
|
||||
const seen = new Set<string>()
|
||||
const push = (provider: string, model: string, label?: string) => {
|
||||
if (!model) return
|
||||
const key = hybridKey(provider, model)
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
collected.push({ provider, model, label: label || model })
|
||||
}
|
||||
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke("models:list", null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = p.models || []
|
||||
}
|
||||
} catch { /* offline — BYOK entries below still load */ }
|
||||
for (const m of catalog["rowboat"] || []) push("rowboat", m.id, m.name || m.id)
|
||||
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
parsed = JSON.parse(configResult.data)
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
const providersMap = (parsed.providers ?? {}) as Record<string, Record<string, unknown>>
|
||||
for (const [flavor, entry] of Object.entries(providersMap)) {
|
||||
const hasKey = typeof entry.apiKey === "string" && (entry.apiKey as string).trim().length > 0
|
||||
const hasBaseURL = typeof entry.baseURL === "string" && (entry.baseURL as string).trim().length > 0
|
||||
if (!hasKey && !hasBaseURL) continue
|
||||
push(flavor, typeof entry.model === "string" ? entry.model : "")
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(flavor, m.id, m.name || m.id)
|
||||
} else {
|
||||
for (const m of Array.isArray(entry.models) ? entry.models as string[] : []) push(flavor, m)
|
||||
}
|
||||
}
|
||||
setOptions(collected)
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
// Current selections. Legacy string overrides pair with the BYOK
|
||||
// top-level flavor (mirrors core/models/defaults.ts).
|
||||
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
|
||||
const toKey = (value: unknown): string => {
|
||||
if (!value) return ""
|
||||
const toRef = (value: unknown): ModelRef | null => {
|
||||
if (!value) return null
|
||||
if (typeof value === "string") {
|
||||
return typeof legacyFlavor === "string" ? hybridKey(legacyFlavor, value) : ""
|
||||
return typeof legacyFlavor === "string" ? { provider: legacyFlavor, model: value } : null
|
||||
}
|
||||
const ref = value as { provider?: unknown; model?: unknown }
|
||||
return typeof ref.provider === "string" && typeof ref.model === "string"
|
||||
? hybridKey(ref.provider, ref.model)
|
||||
: ""
|
||||
? { provider: ref.provider, model: ref.model }
|
||||
: null
|
||||
}
|
||||
setSelectedDefault(toKey(parsed.defaultSelection))
|
||||
setSelectedKg(toKey(parsed.knowledgeGraphModel))
|
||||
setSelectedLiveNote(toKey(parsed.liveNoteAgentModel))
|
||||
setSelectedAutoPermission(toKey(parsed.autoPermissionDecisionModel))
|
||||
setSelectedDefault(toRef(parsed.defaultSelection))
|
||||
setSelectedKg(toRef(parsed.knowledgeGraphModel))
|
||||
setSelectedLiveNote(toRef(parsed.liveNoteAgentModel))
|
||||
setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel))
|
||||
} catch {
|
||||
toast.error("Failed to load models")
|
||||
} finally {
|
||||
|
|
@ -1664,12 +1649,11 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const toRef = (key: string) => (key ? parseHybridKey(key) : null)
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
defaultSelection: toRef(selectedDefault),
|
||||
knowledgeGraphModel: toRef(selectedKg),
|
||||
liveNoteAgentModel: toRef(selectedLiveNote),
|
||||
autoPermissionDecisionModel: toRef(selectedAutoPermission),
|
||||
defaultSelection: selectedDefault,
|
||||
knowledgeGraphModel: selectedKg,
|
||||
liveNoteAgentModel: selectedLiveNote,
|
||||
autoPermissionDecisionModel: selectedAutoPermission,
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Model configuration saved")
|
||||
|
|
@ -1680,33 +1664,19 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
}
|
||||
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
|
||||
|
||||
const renderSelect = (
|
||||
const renderModelField = (
|
||||
label: string,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
defaultLabel: string,
|
||||
value: ModelRef | null,
|
||||
onChange: (v: ModelRef | null) => void,
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">{defaultLabel}</SelectItem>
|
||||
{options.map((o) => {
|
||||
const key = hybridKey(o.provider, o.model)
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
{o.label}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[o.provider] || o.provider}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: "Rowboat default" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -1724,10 +1694,10 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
|
||||
</p>
|
||||
|
||||
{renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")}
|
||||
{renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")}
|
||||
{renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")}
|
||||
{renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")}
|
||||
{renderModelField("Assistant model", selectedDefault, setSelectedDefault)}
|
||||
{renderModelField("Knowledge graph model", selectedKg, setSelectedKg)}
|
||||
{renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)}
|
||||
{renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)}
|
||||
|
||||
{/* Save */}
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
|
|
@ -2452,6 +2422,226 @@ function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Advanced (runtime/cost controls) tab ---
|
||||
|
||||
const MODEL_CALL_LIMIT_MIN = 1
|
||||
const MODEL_CALL_LIMIT_MAX = 500
|
||||
|
||||
function parseLimit(value: string): number | null {
|
||||
const n = Number(value.trim())
|
||||
if (!Number.isInteger(n) || n < MODEL_CALL_LIMIT_MIN || n > MODEL_CALL_LIMIT_MAX) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact segmented − / value / + stepper. The native number-input spinners
|
||||
* are replaced entirely: typing is free-form digits, stepping clamps to the
|
||||
* range and commits immediately. An empty value steps from `fallback` (the
|
||||
* chat field starts from the global limit).
|
||||
*/
|
||||
function LimitStepper({
|
||||
value,
|
||||
fallback,
|
||||
placeholder,
|
||||
onInput,
|
||||
onCommit,
|
||||
}: {
|
||||
value: string
|
||||
fallback: number
|
||||
placeholder?: string
|
||||
/** Every keystroke (no save). */
|
||||
onInput: (next: string) => void
|
||||
/** A settled change: step click or blur. */
|
||||
onCommit: (next: string) => void
|
||||
}) {
|
||||
const current = parseLimit(value)
|
||||
|
||||
const step = (delta: number) => {
|
||||
// From an empty/invalid field, the first step lands on the fallback so
|
||||
// the override starts where the effective value already is.
|
||||
const next = current === null
|
||||
? Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, fallback))
|
||||
: Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, current + delta))
|
||||
onCommit(String(next))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-8 items-center overflow-hidden rounded-md border border-input bg-background shadow-xs shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Decrease limit"
|
||||
onClick={() => step(-1)}
|
||||
disabled={current !== null && current <= MODEL_CALL_LIMIT_MIN}
|
||||
className="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onInput(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
onBlur={() => onCommit(value)}
|
||||
className={cn(
|
||||
"h-full border-x border-input bg-transparent text-center text-sm tabular-nums outline-none",
|
||||
// The 11px placeholder sits on the 14px text baseline, so it reads
|
||||
// slightly low; nudge it up for optical centering. Only applies
|
||||
// while the placeholder is visible, so typed text is unaffected.
|
||||
"placeholder:text-[11px] placeholder:text-muted-foreground/70 placeholder-shown:pb-1",
|
||||
placeholder ? "w-24" : "w-16",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Increase limit"
|
||||
onClick={() => step(1)}
|
||||
disabled={current !== null && current >= MODEL_CALL_LIMIT_MAX}
|
||||
className="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
// Inputs are kept as strings so the user can clear a field while typing;
|
||||
// validation happens on commit (step click or blur).
|
||||
const [globalLimit, setGlobalLimit] = useState("")
|
||||
const [chatLimit, setChatLimit] = useState("")
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
window.ipc.invoke("turnLimits:getSettings", null)
|
||||
.then((settings) => {
|
||||
if (cancelled) return
|
||||
setGlobalLimit(String(settings.maxModelCalls))
|
||||
// A chat override equal to the global limit is no override — show
|
||||
// "Same as above" (legacy files; saves already collapse this).
|
||||
setChatLimit(
|
||||
settings.chatMaxModelCalls !== undefined && settings.chatMaxModelCalls !== settings.maxModelCalls
|
||||
? String(settings.chatMaxModelCalls)
|
||||
: ""
|
||||
)
|
||||
setLoaded(true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) toast.error("Failed to load advanced settings")
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [dialogOpen])
|
||||
|
||||
// Saves silently on success (a toast per stepper click would be noisy,
|
||||
// matching the notification toggles); errors still surface.
|
||||
const saveLimits = useCallback(async (globalStr: string, chatStr: string) => {
|
||||
const global = parseLimit(globalStr)
|
||||
if (global === null) {
|
||||
toast.error(`Model-call limit must be a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
|
||||
return
|
||||
}
|
||||
let chat: number | undefined
|
||||
if (chatStr.trim() !== "") {
|
||||
const parsed = parseLimit(chatStr)
|
||||
if (parsed === null) {
|
||||
toast.error(`Chat limit must be empty or a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
|
||||
return
|
||||
}
|
||||
chat = parsed
|
||||
}
|
||||
// An override equal to the global limit is meaningless — persist it as
|
||||
// "use the global limit" so the field reopens as "Same as above".
|
||||
if (chat === global) chat = undefined
|
||||
try {
|
||||
await window.ipc.invoke("turnLimits:setSettings", {
|
||||
maxModelCalls: global,
|
||||
...(chat === undefined ? {} : { chatMaxModelCalls: chat }),
|
||||
})
|
||||
} catch {
|
||||
toast.error("Failed to save model-call limits")
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-sm text-muted-foreground leading-relaxed">
|
||||
Runtime cost and safety controls. A turn is stopped once it reaches its model-call limit;
|
||||
changes apply to newly started turns only.
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border px-3 py-3 flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Model-call limit</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Maximum model calls per turn. Applies to everything by default — background and
|
||||
knowledge work, scheduled agents, and sub-agents (it also caps sub-agent budgets).
|
||||
</div>
|
||||
</div>
|
||||
<LimitStepper
|
||||
value={globalLimit}
|
||||
fallback={DEFAULT_TURN_LIMITS_SETTINGS.maxModelCalls}
|
||||
onInput={setGlobalLimit}
|
||||
onCommit={(next) => {
|
||||
setGlobalLimit(next)
|
||||
void saveLimits(next, chatLimit)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border px-3 py-3 flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Chat model-call limit</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Optional separate limit for interactive chat turns. Leave empty to use the
|
||||
model-call limit above.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{chatLimit.trim() !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Use the global limit"
|
||||
title="Use the global limit"
|
||||
onClick={() => {
|
||||
setChatLimit("")
|
||||
void saveLimits(globalLimit, "")
|
||||
}}
|
||||
className="flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<LimitStepper
|
||||
value={chatLimit}
|
||||
fallback={parseLimit(globalLimit) ?? DEFAULT_TURN_LIMITS_SETTINGS.maxModelCalls}
|
||||
placeholder="Same as above"
|
||||
onInput={setChatLimit}
|
||||
onCommit={(next) => {
|
||||
setChatLimit(next)
|
||||
// An emptied chat field on blur means "use the global
|
||||
// limit" — persist the override removal.
|
||||
void saveLimits(globalLimit, next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||
|
|
@ -2504,7 +2694,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications" || tab === "advanced") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -2626,7 +2816,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications" || activeTab === "advanced") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
|
|
@ -2665,6 +2855,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<AppearanceSettings />
|
||||
) : activeTab === "notifications" ? (
|
||||
<NotificationSettings dialogOpen={open} />
|
||||
) : activeTab === "advanced" ? (
|
||||
<AdvancedSettings dialogOpen={open} />
|
||||
) : activeTab === "help" ? (
|
||||
<HelpSettings />
|
||||
) : activeTab === "code-mode" ? (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "@/components/ui/alert-dialog"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { CreditRewards } from "@/components/settings/credit-rewards"
|
||||
import { toast } from "sonner"
|
||||
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||
const { billing, isLoading: billingLoading, refresh: refreshBilling } = useBilling(isRowboatConnected)
|
||||
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
|
||||
|
||||
|
|
@ -100,6 +101,14 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
// A confirmed reward grant changes the bonus-credit balance; refetch so the
|
||||
// Earn-credits section shows the updated number while the dialog is open.
|
||||
useEffect(() => {
|
||||
return window.ipc.on('credits:didActivate', () => {
|
||||
refreshBilling()
|
||||
})
|
||||
}, [refreshBilling])
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
try {
|
||||
setConnecting(true)
|
||||
|
|
@ -229,6 +238,11 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Earn Credits Section */}
|
||||
<CreditRewards store={billing?.store ?? null} />
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Payment Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
166
apps/x/apps/renderer/src/components/settings/credit-rewards.tsx
Normal file
166
apps/x/apps/renderer/src/components/settings/credit-rewards.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Check, Copy, Gift, UserPlus } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CREDIT_ACTIVITY_ICONS, useCreditsState } from "@/hooks/use-credits-state"
|
||||
import { InviteCodeClaim } from "@/components/invite-code-claim"
|
||||
import { formatCreditsAsDollars } from "@x/shared/dist/credits.js"
|
||||
import type { BillingStoreBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
interface CreditRewardsProps {
|
||||
// bonus-credit balance from billing info; null while billing is loading
|
||||
store: BillingStoreBucket | null
|
||||
}
|
||||
|
||||
/**
|
||||
* "Earn credits" settings section: the first-time actions that grant bonus
|
||||
* credits, which are done, and the current bonus balance. Claimed state
|
||||
* refreshes live when a `credits:didActivate` event arrives (the parent
|
||||
* refreshes the balance itself).
|
||||
*/
|
||||
export function CreditRewards({ store }: CreditRewardsProps) {
|
||||
const state = useCreditsState()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Hidden while loading, when the feature flag is off, when not eligible
|
||||
// (rewards are for signed-in free-tier users — not BYOK, not paid plans),
|
||||
// or when there is nothing to show (no reward catalog and no referral).
|
||||
if (!state || !state.enabled || !state.eligible) return null
|
||||
if (state.activities.length === 0 && !state.referral) return null
|
||||
|
||||
const referral = state.referral
|
||||
const inviteSlotsLeft = referral ? Math.max(0, referral.maxClaims - referral.claimsUsed) : 0
|
||||
// invites count as one list item, earned once every claim slot is used
|
||||
const totalCount = state.activities.length + (referral ? 1 : 0)
|
||||
const earnedCount =
|
||||
state.activities.filter((a) => a.claimed).length + (referral && inviteSlotsLeft === 0 ? 1 : 0)
|
||||
|
||||
const copyInviteCode = async () => {
|
||||
if (!referral) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(referral.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy invite code:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="size-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Earn credits</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try these for the first time and we'll add bonus credits to your account.
|
||||
</p>
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{earnedCount} of {totalCount} earned
|
||||
</p>
|
||||
{store && (
|
||||
<p className="text-xs font-medium tabular-nums">
|
||||
{formatCreditsAsDollars(store.availableCredits)} bonus credits available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{state.activities.map((activity) => {
|
||||
const Icon = CREDIT_ACTIVITY_ICONS[activity.code] ?? Gift
|
||||
return (
|
||||
<div key={activity.code} className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full",
|
||||
activity.claimed ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? (
|
||||
<Check className="size-4 text-emerald-600" />
|
||||
) : (
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn("text-sm font-medium", activity.claimed && "text-muted-foreground")}>
|
||||
{activity.title}
|
||||
</p>
|
||||
{activity.description && (
|
||||
<p className="text-xs text-muted-foreground">{activity.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
|
||||
activity.claimed
|
||||
? "bg-emerald-500/15 text-emerald-600"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? "Earned" : `+${formatCreditsAsDollars(activity.credits)}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{referral && (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full",
|
||||
inviteSlotsLeft === 0 ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0 ? (
|
||||
<Check className="size-4 text-emerald-600" />
|
||||
) : (
|
||||
<UserPlus className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn("text-sm font-medium", inviteSlotsLeft === 0 && "text-muted-foreground")}>
|
||||
Invite friends
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{inviteSlotsLeft === 0
|
||||
? `All ${referral.maxClaims} invites used`
|
||||
: `You each get ${formatCreditsAsDollars(referral.referrerCredits)} when a friend signs up with your code · ${referral.claimsUsed} of ${referral.maxClaims} joined`}
|
||||
</p>
|
||||
{inviteSlotsLeft > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<code className="rounded-md bg-muted px-2 py-1 font-mono text-xs tracking-wider">
|
||||
{referral.code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyInviteCode}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="size-3 text-emerald-600" /> : <Copy className="size-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
|
||||
inviteSlotsLeft === 0
|
||||
? "bg-emerald-500/15 text-emerald-600"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0
|
||||
? "Earned"
|
||||
: `+${formatCreditsAsDollars(referral.referrerCredits)} each`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InviteCodeClaim />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import * as React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AppWindow,
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
ChevronRight,
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
LayoutGrid,
|
||||
Mic,
|
||||
MoreVertical,
|
||||
PanelLeftClose,
|
||||
Pencil,
|
||||
Pin,
|
||||
SquarePen,
|
||||
|
|
@ -71,9 +73,17 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getPinnedApps, onPinnedAppsChanged, unpinApp } from "@/lib/pinned-apps"
|
||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { SidebarCreditRewards } from "@/components/sidebar-credit-rewards"
|
||||
import { MascotFaceIcon } from "@/components/talking-head"
|
||||
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
|
|
@ -180,6 +190,8 @@ type SidebarContentPanelProps = {
|
|||
onOpenCode?: () => void
|
||||
onOpenBgTasks?: () => void
|
||||
onOpenApps?: () => void
|
||||
/** Open a specific app (pinned in the sidebar) inside the Apps view. */
|
||||
onOpenApp?: (folder: string) => void
|
||||
onOpenAgent?: (slug: string) => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
||||
onOpenRun?: (runId: string) => void
|
||||
|
|
@ -436,6 +448,7 @@ export function SidebarContentPanel({
|
|||
onOpenCode,
|
||||
onOpenBgTasks,
|
||||
onOpenApps,
|
||||
onOpenApp,
|
||||
recentRuns = [],
|
||||
onOpenRun,
|
||||
onRenameRun,
|
||||
|
|
@ -604,6 +617,27 @@ export function SidebarContentPanel({
|
|||
return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))]
|
||||
}, [recentRuns, pinnedChatIds])
|
||||
|
||||
// Apps pinned to the sidebar (right-click an app card in the Apps view).
|
||||
// Names resolve via apps:list; until then rows show the folder slug, and
|
||||
// pins whose app no longer exists are hidden (but kept in storage).
|
||||
const [pinnedAppFolders, setPinnedAppFolders] = useState<string[]>(() => getPinnedApps())
|
||||
const [pinnedAppNames, setPinnedAppNames] = useState<Map<string, string> | null>(null)
|
||||
useEffect(() => onPinnedAppsChanged(setPinnedAppFolders), [])
|
||||
useEffect(() => {
|
||||
if (pinnedAppFolders.length === 0) return
|
||||
let cancelled = false
|
||||
void window.ipc.invoke('apps:list', {})
|
||||
.then((r) => {
|
||||
if (cancelled) return
|
||||
setPinnedAppNames(new Map(r.apps.map((a) => [a.folder, a.manifest?.name ?? a.folder])))
|
||||
})
|
||||
.catch(() => { /* fall back to folder names */ })
|
||||
return () => { cancelled = true }
|
||||
}, [pinnedAppFolders])
|
||||
const pinnedApps = pinnedAppFolders
|
||||
.filter((f) => pinnedAppNames === null || pinnedAppNames.has(f))
|
||||
.map((f) => ({ folder: f, name: pinnedAppNames?.get(f) ?? f }))
|
||||
|
||||
// Chat pending delete confirmation, if any.
|
||||
const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
|
||||
|
|
@ -966,6 +1000,24 @@ export function SidebarContentPanel({
|
|||
<span className="flex-1 truncate">Apps</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{pinnedApps.map(({ folder, name }) => (
|
||||
<SidebarMenuItem key={folder}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuButton onClick={() => onOpenApp?.(folder)} className="pl-7">
|
||||
<AppWindow className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
</SidebarMenuButton>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => unpinApp(folder)}>
|
||||
<PanelLeftClose className="mr-2 size-3.5" />
|
||||
Remove from sidebar
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-agents"
|
||||
|
|
@ -1140,6 +1192,14 @@ export function SidebarContentPanel({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SidebarContent>
|
||||
{/* First-time-action credit rewards (feature-flagged, signed-in only) */}
|
||||
<SidebarCreditRewards
|
||||
onOpenEmail={onOpenEmail}
|
||||
onOpenMeetings={onOpenMeetings}
|
||||
onOpenAgents={onOpenBgTasks}
|
||||
onOpenApps={onOpenApps}
|
||||
onConnectAccounts={() => setConnectionsSettingsOpen(true)}
|
||||
/>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{isRowboatConnected && billing ? (() => {
|
||||
const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
|
||||
|
|
|
|||
234
apps/x/apps/renderer/src/components/sidebar-credit-rewards.tsx
Normal file
234
apps/x/apps/renderer/src/components/sidebar-credit-rewards.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { Check, ChevronRight, Copy, Gift, UserPlus, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { CREDIT_ACTIVITY_ICONS, useCreditsState } from "@/hooks/use-credits-state"
|
||||
import { formatCreditsAsDollars, type CreditActivityCode } from "@x/shared/dist/credits.js"
|
||||
|
||||
const DISMISSED_KEY = "rowboat.credit-rewards-card-dismissed"
|
||||
|
||||
interface SidebarCreditRewardsProps {
|
||||
onOpenEmail?: () => void
|
||||
onOpenMeetings?: () => void
|
||||
onOpenAgents?: () => void
|
||||
onOpenApps?: () => void
|
||||
onConnectAccounts: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent sidebar entry for first-time-action credit rewards: a compact
|
||||
* "Earn $X in credits" pill above the plan card that opens the checklist in
|
||||
* a popover. Rows navigate to where the action happens. Shown only when the
|
||||
* credit-rewards feature flag is on and the user is an eligible (signed-in,
|
||||
* free-tier) account; gone for good once every reward is earned or the user
|
||||
* dismisses it from the popover.
|
||||
*/
|
||||
export function SidebarCreditRewards({
|
||||
onOpenEmail,
|
||||
onOpenMeetings,
|
||||
onOpenAgents,
|
||||
onOpenApps,
|
||||
onConnectAccounts,
|
||||
}: SidebarCreditRewardsProps) {
|
||||
const state = useCreditsState()
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISSED_KEY) === "1")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
localStorage.setItem(DISMISSED_KEY, "1")
|
||||
setDismissed(true)
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (dismissed || !state || !state.enabled || !state.eligible) return null
|
||||
|
||||
const referral = state.referral
|
||||
const inviteSlotsLeft = referral ? Math.max(0, referral.maxClaims - referral.claimsUsed) : 0
|
||||
const remaining = state.activities.filter((a) => !a.claimed)
|
||||
if (remaining.length === 0 && inviteSlotsLeft === 0) return null
|
||||
|
||||
// invites count as one checklist item, earned once every claim slot is used
|
||||
const totalCount = state.activities.length + (referral ? 1 : 0)
|
||||
const earnedCount =
|
||||
state.activities.length - remaining.length + (referral && inviteSlotsLeft === 0 ? 1 : 0)
|
||||
const remainingTotal =
|
||||
remaining.reduce((sum, a) => sum + a.credits, 0) +
|
||||
(referral ? inviteSlotsLeft * referral.referrerCredits : 0)
|
||||
|
||||
const copyInviteCode = async () => {
|
||||
if (!referral) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(referral.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy invite code:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const actionFor = (code: CreditActivityCode): (() => void) | undefined => {
|
||||
switch (code) {
|
||||
case "first_gmail_connected": return onConnectAccounts
|
||||
case "first_email_sent": return onOpenEmail
|
||||
case "first_meeting_note": return onOpenMeetings
|
||||
case "first_bg_agent": return onOpenAgents
|
||||
case "first_app_built": return onOpenApps
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/40"
|
||||
>
|
||||
<Gift className="size-4 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-sidebar-foreground">
|
||||
Earn {formatCreditsAsDollars(remainingTotal)} in credits
|
||||
</span>
|
||||
<span className="block text-[10px] text-sidebar-foreground/60">
|
||||
{earnedCount} of {totalCount} earned
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="size-3 shrink-0 text-sidebar-foreground/50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" sideOffset={10} className="w-80 p-0">
|
||||
<PopoverArrow className="fill-popover" />
|
||||
<div className="flex items-start justify-between gap-2 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-amber-500/15">
|
||||
<Gift className="size-4 text-amber-500" />
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Earn {formatCreditsAsDollars(remainingTotal)} in credits</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try these firsts and we'll credit your account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Dismiss earn-credits checklist"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{state.activities.map((activity) => {
|
||||
const Icon = CREDIT_ACTIVITY_ICONS[activity.code] ?? Gift
|
||||
const action = actionFor(activity.code)
|
||||
return (
|
||||
<button
|
||||
key={activity.code}
|
||||
type="button"
|
||||
disabled={activity.claimed || !action}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
action?.()
|
||||
}}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left",
|
||||
activity.claimed ? "opacity-60" : "transition-colors hover:bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-full",
|
||||
activity.claimed ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? (
|
||||
<Check className="size-3.5 text-emerald-600" />
|
||||
) : (
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-[13px]",
|
||||
activity.claimed ? "text-muted-foreground line-through" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{activity.title}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums",
|
||||
activity.claimed ? "text-muted-foreground" : "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{activity.claimed ? "Earned" : `+${formatCreditsAsDollars(activity.credits)}`}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{referral && (
|
||||
<div className="border-t p-2">
|
||||
<div className="flex items-center gap-2.5 px-2 py-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-full",
|
||||
inviteSlotsLeft === 0 ? "bg-emerald-500/15" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0 ? (
|
||||
<Check className="size-3.5 text-emerald-600" />
|
||||
) : (
|
||||
<UserPlus className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] font-medium">Invite friends</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{referral.claimsUsed} of {referral.maxClaims} joined
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums",
|
||||
inviteSlotsLeft === 0 ? "text-muted-foreground" : "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{inviteSlotsLeft === 0
|
||||
? "Earned"
|
||||
: `+${formatCreditsAsDollars(referral.referrerCredits)} each`}
|
||||
</span>
|
||||
</div>
|
||||
{inviteSlotsLeft > 0 && (
|
||||
<div className="mx-2 mb-1 flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded-md bg-muted px-2 py-1 text-center font-mono text-xs tracking-wider">
|
||||
{referral.code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyInviteCode}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="size-3 text-emerald-600" /> : <Copy className="size-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{inviteSlotsLeft > 0 && (
|
||||
<p className="mx-2 mb-1 text-[11px] leading-snug text-muted-foreground">
|
||||
Share your code — you each get {formatCreditsAsDollars(referral.referrerCredits)} when a
|
||||
friend signs up and enters it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,10 +6,16 @@ import {
|
|||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { useTheme } from "@/contexts/theme-context"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
// Without this, sonner defaults to its light theme: our inline vars keep
|
||||
// the background/title correct in dark mode, but the description falls
|
||||
// back to sonner's hardcoded light-theme gray — dark text on dark bg.
|
||||
const { resolvedTheme } = useTheme()
|
||||
return (
|
||||
<Sonner
|
||||
theme={resolvedTheme}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
|
|
@ -18,6 +24,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
// Sonner styles toast parts with attribute selectors that outrank plain
|
||||
// utility classes, hence the trailing-! (important) utilities.
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"bg-popover/90! backdrop-blur-xl! text-popover-foreground! border-border/60! rounded-xl! shadow-xl! shadow-black/10! gap-3! p-4!",
|
||||
description: "text-muted-foreground! leading-relaxed! mt-0.5!",
|
||||
actionButton:
|
||||
"bg-primary! text-primary-foreground! rounded-md! font-medium! px-3! transition-colors! hover:bg-primary/85!",
|
||||
cancelButton:
|
||||
"bg-transparent! text-muted-foreground! border! border-solid! border-border! rounded-md! transition-colors! hover:bg-muted! hover:text-foreground!",
|
||||
},
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
|
|
|
|||
122
apps/x/apps/renderer/src/components/update-card.tsx
Normal file
122
apps/x/apps/renderer/src/components/update-card.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useEffect, useRef, useState } from "react"
|
||||
import { X } from "lucide-react"
|
||||
import { Streamdown } from "streamdown"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { updatePrompted } from "@/lib/analytics"
|
||||
import type { ipc as ipcShared } from "@x/shared"
|
||||
|
||||
type UpdaterStatus = ipcShared.IPCChannels["updater:status"]["req"]
|
||||
|
||||
const RELEASES_URL = "https://github.com/rowboatlabs/rowboat/releases"
|
||||
|
||||
/**
|
||||
* Bottom-left "Update available" card, shown once an update is staged. By
|
||||
* that point Squirrel has already installed it — the card only asks for the
|
||||
* restart (Chrome-style) and shows the release notes so new features aren't
|
||||
* shipped silently. "Later"/× dismiss it for this session; the update still
|
||||
* applies on the next natural restart.
|
||||
*/
|
||||
export function UpdateCard() {
|
||||
const [status, setStatus] = useState<UpdaterStatus | null>(null)
|
||||
// The version the user dismissed — if a newer update stages afterwards,
|
||||
// the card re-offers itself for that one.
|
||||
const [dismissedFor, setDismissedFor] = useState<string | null>(null)
|
||||
const promptedForRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.ipc
|
||||
.invoke("updater:getStatus", null)
|
||||
.then((s) => {
|
||||
if (!cancelled) setStatus(s)
|
||||
})
|
||||
.catch(() => {})
|
||||
const unsubscribe = window.ipc.on("updater:status", setStatus)
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ready = status?.state === "ready"
|
||||
// newVersion may be "1.4.0" (Squirrel.Windows) or "v1.4.0" (GitHub tag).
|
||||
const version = ready ? status.newVersion?.replace(/^v/, "") : undefined
|
||||
const versionKey = version ?? "unknown"
|
||||
const visible = ready && dismissedFor !== versionKey
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && promptedForRef.current !== versionKey) {
|
||||
updatePrompted()
|
||||
promptedForRef.current = versionKey
|
||||
}
|
||||
}, [visible, versionKey])
|
||||
|
||||
if (!visible || status?.state !== "ready") return null
|
||||
|
||||
const releaseUrl = version ? `${RELEASES_URL}/tag/v${version}` : `${RELEASES_URL}/latest`
|
||||
// Release bodies usually open with their own "What's new" heading, which
|
||||
// would duplicate the card's label above the box — drop it.
|
||||
const releaseNotes = status.releaseNotes
|
||||
?.replace(/^\s*#{1,6}[ \t]*what[’']?s new[ \t]*\r?\n+/i, "")
|
||||
.trim()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-4 left-4 z-50 w-[340px] rounded-xl border border-border/60 bg-popover/95 backdrop-blur-xl p-4 shadow-xl shadow-black/10 animate-in fade-in slide-in-from-bottom-4 duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-blue-500 shrink-0" aria-hidden />
|
||||
<h4 className="text-sm font-semibold">Update available</h4>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{version && <Badge variant="secondary">v{version}</Badge>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissedFor(versionKey)}
|
||||
aria-label="Dismiss"
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
A new version is ready to install. Restart to start using it.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<h5 className="text-xs font-semibold">What's new</h5>
|
||||
{releaseNotes ? (
|
||||
// A bordered, fixed-height scroll box (not a bare max-h clip): the
|
||||
// frame and inset scrollbar signal there is more content below the
|
||||
// fold on every platform.
|
||||
<div className="mt-1.5 max-h-56 overflow-y-auto overscroll-contain rounded-md border border-border/60 bg-muted/30 p-2.5">
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-xs [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{releaseNotes}
|
||||
</Streamdown>
|
||||
</div>
|
||||
) : (
|
||||
// Releases are expected to carry notes; if this one doesn't, show
|
||||
// a static line instead of an empty pane.
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">Bug fixes and improvements.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDismissedFor(versionKey)}>
|
||||
Later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
onClick={() => window.open(releaseUrl, "_blank")}
|
||||
>
|
||||
Release notes
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void window.ipc.invoke("updater:quitAndInstall", null)}>
|
||||
Restart now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
if (error) return <div className="chart-block-error-msg">{error}</div>
|
||||
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
|
||||
|
||||
const series = blocks.chartSeries(config!)
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
{config!.chart === 'line' ? (
|
||||
|
|
@ -73,7 +74,9 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
|
||||
{series.map((key, index) => (
|
||||
<Line key={key} type="monotone" dataKey={key} stroke={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</LineChart>
|
||||
) : config!.chart === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
|
|
@ -82,13 +85,15 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={config!.y} fill="#8884d8" />
|
||||
{series.map((key, index) => (
|
||||
<Bar key={key} dataKey={key} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
|
||||
<Pie data={data} dataKey={series[0]} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
|
|
|
|||
49
apps/x/apps/renderer/src/hooks/use-credits-state.ts
Normal file
49
apps/x/apps/renderer/src/hooks/use-credits-state.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ElementType } from 'react'
|
||||
import { AtSign, Bot, LayoutGrid, NotebookPen, Send } from 'lucide-react'
|
||||
import type { CreditActivityCode, CreditsState } from '@x/shared/dist/credits.js'
|
||||
|
||||
export const CREDIT_ACTIVITY_ICONS: Record<CreditActivityCode, ElementType> = {
|
||||
first_gmail_connected: AtSign,
|
||||
first_email_sent: Send,
|
||||
first_meeting_note: NotebookPen,
|
||||
first_bg_agent: Bot,
|
||||
first_app_built: LayoutGrid,
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit-rewards state shared by every rewards surface (sidebar pill,
|
||||
* settings section). Fetches once on mount and refreshes when a grant is
|
||||
* confirmed (`credits:didActivate`) or when a connect event could change
|
||||
* eligibility/claimed state — rowboat sign-in/out and Google connects; other
|
||||
* providers can't affect rewards, so their events are ignored.
|
||||
*/
|
||||
export function useCreditsState() {
|
||||
const [state, setState] = useState<CreditsState | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setState(await window.ipc.invoke('credits:getState', null))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credit rewards state:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const offActivated = window.ipc.on('credits:didActivate', () => {
|
||||
refresh()
|
||||
})
|
||||
const offOAuth = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider === 'rowboat' || event.provider === 'google') {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
offActivated()
|
||||
offOAuth()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return state
|
||||
}
|
||||
100
apps/x/apps/renderer/src/hooks/use-models.test.tsx
Normal file
100
apps/x/apps/renderer/src/hooks/use-models.test.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { __resetModelsForTests, useModels } from './use-models'
|
||||
|
||||
// The hook wires a module-level store to window.ipc, so the tests stub the
|
||||
// preload surface: `invoke` routes by channel through a per-test handler map
|
||||
// and counts calls per channel to observe the store's fetch dedupe.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
let invokeCounts: Record<string, number> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
function serveConfig(providers: Record<string, unknown>): void {
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4', reasoning: true }, { id: 'gpt-5.4-mini' }] },
|
||||
],
|
||||
})
|
||||
handlers['workspace:readFile'] = async () => ({
|
||||
data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers }),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelsForTests()
|
||||
handlers = {}
|
||||
invokeCounts = {}
|
||||
})
|
||||
|
||||
describe('useModels', () => {
|
||||
it('shares one fetch across concurrently mounted consumers', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
|
||||
const first = renderHook(() => useModels())
|
||||
const second = renderHook(() => useModels())
|
||||
|
||||
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
|
||||
await waitFor(() => expect(second.result.current.groups.length).toBeGreaterThan(0))
|
||||
|
||||
expect(invokeCounts['models:list']).toBe(1)
|
||||
expect(invokeCounts['workspace:readFile']).toBe(1)
|
||||
expect(first.result.current.groups).toEqual([
|
||||
{ kind: 'catalog', flavor: 'openai', models: ['gpt-5.4', 'gpt-5.4-mini'] },
|
||||
])
|
||||
expect(first.result.current.reasoningByKey).toEqual({ 'openai/gpt-5.4': true })
|
||||
expect(first.result.current.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' })
|
||||
// Raw catalog is exposed for provider-scoped pickers (unconfigured
|
||||
// providers have no group but may have a catalog).
|
||||
expect(first.result.current.catalogByProvider).toEqual({ openai: ['gpt-5.4', 'gpt-5.4-mini'] })
|
||||
// Both consumers see the same store snapshot, not copies.
|
||||
expect(second.result.current.groups).toBe(first.result.current.groups)
|
||||
})
|
||||
|
||||
it('serves the cache to late mounts without refetching', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
|
||||
const first = renderHook(() => useModels())
|
||||
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
|
||||
|
||||
const late = renderHook(() => useModels())
|
||||
// Loaded synchronously from the module cache — no loading flash, no IPC.
|
||||
expect(late.result.current.groups.length).toBeGreaterThan(0)
|
||||
expect(invokeCounts['models:list']).toBe(1)
|
||||
})
|
||||
|
||||
it('refetches on models-config-changed and updates every consumer', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
|
||||
const { result } = renderHook(() => useModels())
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(1))
|
||||
|
||||
serveConfig({
|
||||
openai: { apiKey: 'sk-test', model: 'gpt-5.4' },
|
||||
ollama: { baseURL: 'http://localhost:11434' },
|
||||
})
|
||||
// The settings Save path: models:updateConfig lands first, then the
|
||||
// event fires — the refetch must see the new default (this is what
|
||||
// moves a fresh composer tab's trigger label without a restart).
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'ollama', model: 'llama3' })
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(2))
|
||||
expect(result.current.groups[1]).toEqual({
|
||||
kind: 'live', flavor: 'ollama', apiKey: '', baseURL: 'http://localhost:11434', savedModel: '',
|
||||
})
|
||||
expect(result.current.defaultModel).toEqual({ provider: 'ollama', model: 'llama3' })
|
||||
expect(invokeCounts['models:list']).toBe(2)
|
||||
})
|
||||
})
|
||||
247
apps/x/apps/renderer/src/hooks/use-models.ts
Normal file
247
apps/x/apps/renderer/src/hooks/use-models.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ProviderModelsFlavor } from './use-provider-models'
|
||||
|
||||
export interface ModelRef {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
// One picker group per connected provider. Catalog groups carry a resolved
|
||||
// model list (models:list / saved config); live groups carry credentials and
|
||||
// fetch their list from the provider inside the dropdown via
|
||||
// useProviderModels (models:listForProvider).
|
||||
export type ModelPickerGroup =
|
||||
| { kind: 'catalog'; flavor: string; models: string[] }
|
||||
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
|
||||
|
||||
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
|
||||
// Catalog-preferred flavors that degrade to a live fetch when models:list has
|
||||
// no catalog for them (signed-in mode returns only the rowboat provider, or
|
||||
// the models.dev cache is empty).
|
||||
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
|
||||
|
||||
export interface ModelsSnapshot {
|
||||
groups: ModelPickerGroup[]
|
||||
// Per-model reasoning capability ("provider/model" → flag) from models:list.
|
||||
// Live-fetched ids carry no reasoning metadata, so lookups miss → treated
|
||||
// as non-reasoning.
|
||||
reasoningByKey: Record<string, boolean>
|
||||
// The effective runtime default (what a run actually uses when the user
|
||||
// hasn't picked a model) — shown in pickers instead of guessing from list
|
||||
// order, which can disagree with the real default.
|
||||
defaultModel: ModelRef | null
|
||||
isRowboatConnected: boolean
|
||||
// Raw models:list catalog per provider id. Groups only cover providers
|
||||
// configured in models.json; provider-scoped pickers fall back to this so
|
||||
// a provider mid-setup (key typed, not saved) still lists its catalog.
|
||||
catalogByProvider: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface UseModelsResult extends ModelsSnapshot {
|
||||
/** Force a refetch now (e.g. a composer tab becoming active). */
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: ModelsSnapshot = {
|
||||
groups: [],
|
||||
reasoningByKey: {},
|
||||
defaultModel: null,
|
||||
isRowboatConnected: false,
|
||||
catalogByProvider: {},
|
||||
}
|
||||
|
||||
// Module-level store: every mounted consumer shares one snapshot and one
|
||||
// in-flight fetch, so N pickers on screen never fan out into N identical
|
||||
// IPC round-trips.
|
||||
let snapshot: ModelsSnapshot = EMPTY_SNAPSHOT
|
||||
let loaded = false
|
||||
let fetching = false
|
||||
let fetchSeq = 0
|
||||
let wired = false
|
||||
let wiredCleanups: Array<() => void> = []
|
||||
const subscribers = new Set<() => void>()
|
||||
|
||||
// Hybrid mode: signed-in users get the gateway list AND every BYOK provider
|
||||
// configured in models.json (selecting a BYOK model routes that message
|
||||
// through the user's own key / local server). Signed-out users get BYOK only.
|
||||
async function buildSnapshot(): Promise<ModelsSnapshot> {
|
||||
let isRowboatConnected = false
|
||||
try {
|
||||
const state = await window.ipc.invoke('oauth:getState', null)
|
||||
isRowboatConnected = state.config?.rowboat?.connected ?? false
|
||||
} catch { /* treat as signed out */ }
|
||||
|
||||
let defaultModel: ModelRef | null = null
|
||||
try {
|
||||
const def = await window.ipc.invoke('llm:getDefaultModel', null)
|
||||
defaultModel = { provider: def.provider, model: def.model }
|
||||
} catch { /* no default resolvable */ }
|
||||
|
||||
const groups: ModelPickerGroup[] = []
|
||||
const reasoningByKey: Record<string, boolean> = {}
|
||||
|
||||
// Full catalog per provider (gateway + models.dev cloud providers).
|
||||
const catalog: Record<string, string[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
|
||||
for (const m of p.models || []) {
|
||||
if (typeof m.reasoning === 'boolean') {
|
||||
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* offline / no catalog — groups fall back to saved config below */ }
|
||||
|
||||
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
|
||||
}
|
||||
|
||||
// ChatGPT subscription (codex): models:list only carries this catalog
|
||||
// while signed in with ChatGPT, so presence is the gate.
|
||||
if ((catalog['codex'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// List the default provider's group first.
|
||||
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
|
||||
const flavors = Object.keys(parsed?.providers || {})
|
||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
|
||||
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
|
||||
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
|
||||
if (!apiKey && !baseURL) continue // provider not configured
|
||||
const savedModel = typeof e.model === 'string' ? e.model : ''
|
||||
|
||||
// Live flavors fetch their list from the provider inside the
|
||||
// dropdown, with the credentials saved in config. Catalog flavors
|
||||
// degrade to the same live fetch when models:list carried no
|
||||
// catalog for them (signed in, or empty models.dev cache).
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
|
||||
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
|
||||
continue
|
||||
}
|
||||
|
||||
// Catalog group: the saved default model leads, then the catalog.
|
||||
// Saved models[] survives as the fallback for unknown flavors the
|
||||
// live fetch doesn't support.
|
||||
const models: string[] = []
|
||||
const push = (model: string) => {
|
||||
if (model && !models.includes(model)) models.push(model)
|
||||
}
|
||||
push(savedModel)
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(m)
|
||||
} else {
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(m)
|
||||
}
|
||||
groups.push({ kind: 'catalog', flavor, models })
|
||||
}
|
||||
|
||||
// The user's explicit default selection leads the picker: its group
|
||||
// first and, within a catalog group, the model itself first. (Live
|
||||
// groups pin the default at the top themselves.)
|
||||
const sel = parsed?.defaultSelection
|
||||
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
|
||||
const index = groups.findIndex((g) => g.flavor === sel.provider)
|
||||
if (index >= 0) {
|
||||
const [group] = groups.splice(index, 1)
|
||||
groups.unshift(group)
|
||||
if (group.kind === 'catalog') {
|
||||
const mi = group.models.indexOf(sel.model)
|
||||
if (mi > 0) {
|
||||
group.models.splice(mi, 1)
|
||||
group.models.unshift(sel.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
return { groups, reasoningByKey, defaultModel, isRowboatConnected, catalogByProvider: catalog }
|
||||
}
|
||||
|
||||
function startFetch(): void {
|
||||
// Concurrent fetches race (an event can fire while one is in flight) —
|
||||
// only the newest run may write the snapshot, else a slow stale run can
|
||||
// clobber the fresh list.
|
||||
const seq = ++fetchSeq
|
||||
fetching = true
|
||||
void buildSnapshot()
|
||||
.then((next) => {
|
||||
if (seq !== fetchSeq) return
|
||||
snapshot = next
|
||||
loaded = true
|
||||
for (const notify of subscribers) notify()
|
||||
})
|
||||
.catch((err) => {
|
||||
// No config yet — but surface unexpected failures for diagnosis.
|
||||
console.error('[use-models] failed to load model list', err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === fetchSeq) fetching = false
|
||||
})
|
||||
}
|
||||
|
||||
function refreshModels(): void {
|
||||
startFetch()
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (!loaded && !fetching) startFetch()
|
||||
}
|
||||
|
||||
function wireGlobalEvents(): void {
|
||||
if (wired) return
|
||||
wired = true
|
||||
// Config edits anywhere in the app (settings dialog, composer pick,
|
||||
// onboarding) announce themselves on this window event.
|
||||
window.addEventListener('models-config-changed', refreshModels)
|
||||
wiredCleanups = [
|
||||
() => window.removeEventListener('models-config-changed', refreshModels),
|
||||
// Rowboat sign-in swaps the whole hybrid list.
|
||||
window.ipc.on('oauth:didConnect', refreshModels),
|
||||
// ChatGPT subscription models appear/disappear with the ChatGPT session.
|
||||
window.ipc.on('chatgpt:statusChanged', refreshModels),
|
||||
]
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
wireGlobalEvents()
|
||||
subscribers.add(onStoreChange)
|
||||
ensureLoaded()
|
||||
return () => {
|
||||
subscribers.delete(onStoreChange)
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): ModelsSnapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function useModels(): UseModelsResult {
|
||||
const data = useSyncExternalStore(subscribe, getSnapshot)
|
||||
return useMemo(() => ({ ...data, refresh: refreshModels }), [data])
|
||||
}
|
||||
|
||||
// Test-only: drop the shared cache and event wiring so each test starts from
|
||||
// a cold store (the seq bump also invalidates any in-flight fetch).
|
||||
export function __resetModelsForTests(): void {
|
||||
snapshot = EMPTY_SNAPSHOT
|
||||
loaded = false
|
||||
fetching = false
|
||||
fetchSeq++
|
||||
subscribers.clear()
|
||||
for (const cleanup of wiredCleanups) cleanup()
|
||||
wiredCleanups = []
|
||||
wired = false
|
||||
}
|
||||
114
apps/x/apps/renderer/src/hooks/useChatGPT.ts
Normal file
114
apps/x/apps/renderer/src/hooks/useChatGPT.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// "Sign in with ChatGPT" state, modeled on useOAuth.ts but against the
|
||||
// dedicated chatgpt:* IPC surface: status is fetched on mount and re-derived
|
||||
// from action results (chatgpt:signIn resolves with the final status — no
|
||||
// broadcast event to listen for).
|
||||
|
||||
type ChatGPTStatus = {
|
||||
signedIn: boolean;
|
||||
email?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export function useChatGPT() {
|
||||
const [status, setStatus] = useState<ChatGPTStatus>({ signedIn: false });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
// Cancelled flag + attempt sequence: a cancelled or superseded attempt's
|
||||
// invoke still resolves later (main settles it with `cancelled: true`);
|
||||
// only the CURRENT attempt may touch isSigningIn or show toasts, so a
|
||||
// stale resolution can't clobber a fresh attempt's waiting UI.
|
||||
const cancelledRef = useRef(false);
|
||||
const attemptSeqRef = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus(await window.ipc.invoke('chatgpt:getStatus', null));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ChatGPT status:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const signIn = useCallback(async () => {
|
||||
if (isSigningIn) return;
|
||||
const attempt = ++attemptSeqRef.current;
|
||||
cancelledRef.current = false;
|
||||
setIsSigningIn(true);
|
||||
// True only for the attempt whose result should drive the UI.
|
||||
const isCurrent = () => attempt === attemptSeqRef.current && !cancelledRef.current;
|
||||
try {
|
||||
const result = await window.ipc.invoke('chatgpt:signIn', null);
|
||||
if (result.signedIn) {
|
||||
// Always reflect a successful sign-in, even if this attempt was
|
||||
// cancelled client-side after the browser flow completed.
|
||||
setStatus({
|
||||
signedIn: true,
|
||||
...(result.email ? { email: result.email } : {}),
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
});
|
||||
if (isCurrent()) {
|
||||
toast.success(result.email ? `Signed in as ${result.email}` : 'Signed in with ChatGPT');
|
||||
}
|
||||
} else if (isCurrent() && !result.cancelled) {
|
||||
toast.error(result.error || 'ChatGPT sign-in failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ChatGPT sign-in failed:', error);
|
||||
if (isCurrent()) {
|
||||
toast.error('ChatGPT sign-in failed');
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
}
|
||||
}, [isSigningIn]);
|
||||
|
||||
const cancelSignIn = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
setIsSigningIn(false);
|
||||
// Really abort the main-process attempt (stops the loopback server and
|
||||
// settles the pending signIn invoke) — otherwise the next Sign In click
|
||||
// would join a dead attempt and never re-open the browser.
|
||||
window.ipc.invoke('chatgpt:cancelSignIn', null).catch((error) => {
|
||||
console.error('Failed to cancel ChatGPT sign-in:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('chatgpt:signOut', null);
|
||||
if (result.success) {
|
||||
setStatus({ signedIn: false });
|
||||
toast.success('Signed out of ChatGPT');
|
||||
} else {
|
||||
toast.error('Failed to sign out of ChatGPT');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ChatGPT sign-out failed:', error);
|
||||
toast.error('Failed to sign out of ChatGPT');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
isLoading,
|
||||
isSigningIn,
|
||||
signIn,
|
||||
cancelSignIn,
|
||||
signOut,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ export function useConnectors(active: boolean) {
|
|||
// but those handlers are no longer reachable in the UI (the gating
|
||||
// condition `useComposioForGoogle` stays false).
|
||||
// TODO follow-up: drop these flags entirely and prune the dead UI branches
|
||||
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
|
||||
// in connectors-popover and connected-accounts-settings.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(false)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,13 @@ export function callTurnLatency(props: {
|
|||
})
|
||||
}
|
||||
|
||||
// Client auto-update funnel: staged (main: update_failed on error) → prompted
|
||||
// (here, when the restart card is shown) → restarted (main, on quitAndInstall)
|
||||
// → client_updated (main, first launch on the new version).
|
||||
export function updatePrompted() {
|
||||
posthog.capture('update_prompted')
|
||||
}
|
||||
|
||||
export function searchExecuted(types: string[]) {
|
||||
posthog.capture('search_executed', { types })
|
||||
}
|
||||
|
|
|
|||
39
apps/x/apps/renderer/src/lib/pinned-apps.ts
Normal file
39
apps/x/apps/renderer/src/lib/pinned-apps.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Apps pinned to the nav sidebar: a per-machine UI preference persisted in
|
||||
// localStorage (same pattern as pinned chats). A window event keeps the
|
||||
// sidebar and the Apps view in sync within the session.
|
||||
|
||||
const STORAGE_KEY = 'x:pinned-apps'
|
||||
export const PINNED_APPS_CHANGED_EVENT = 'x:pinned-apps-changed'
|
||||
|
||||
export function getPinnedApps(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function save(folders: string[]): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(folders))
|
||||
} catch { /* keep in-memory behavior */ }
|
||||
window.dispatchEvent(new Event(PINNED_APPS_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
export function pinApp(folder: string): void {
|
||||
const current = getPinnedApps()
|
||||
if (!current.includes(folder)) save([...current, folder])
|
||||
}
|
||||
|
||||
export function unpinApp(folder: string): void {
|
||||
const current = getPinnedApps()
|
||||
if (current.includes(folder)) save(current.filter((f) => f !== folder))
|
||||
}
|
||||
|
||||
export function onPinnedAppsChanged(cb: (folders: string[]) => void): () => void {
|
||||
const handler = () => cb(getPinnedApps())
|
||||
window.addEventListener(PINNED_APPS_CHANGED_EVENT, handler)
|
||||
return () => window.removeEventListener(PINNED_APPS_CHANGED_EVENT, handler)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
ToolPermissionRequestEvent,
|
||||
} from '@x/shared/src/runs.js'
|
||||
import {
|
||||
MODEL_CALL_LIMIT_ERROR_CODE,
|
||||
deriveTurnStatus,
|
||||
outstandingAsyncTools,
|
||||
outstandingPermissions,
|
||||
|
|
@ -331,10 +332,17 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
|||
}
|
||||
|
||||
if (state.terminal?.type === 'turn_failed') {
|
||||
// Interactive turns normally wrap up gracefully before hitting the
|
||||
// limit; if a hard limit failure still lands here, explain it and point
|
||||
// at the setting instead of showing the raw runtime error.
|
||||
const message = state.terminal.code === MODEL_CALL_LIMIT_ERROR_CODE
|
||||
? `This turn hit its model-call limit of ${state.definition.config.maxModelCalls} before it could finish. ` +
|
||||
'You can raise the limit in Settings → Advanced.'
|
||||
: state.terminal.error
|
||||
items.push({
|
||||
id: `${turnId}:error`,
|
||||
kind: 'error',
|
||||
message: state.terminal.error,
|
||||
message,
|
||||
timestamp: ts(),
|
||||
} satisfies ErrorMessage)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -512,7 +512,13 @@ Rules:
|
|||
- `input` is the user message that defines this turn boundary.
|
||||
- `autoPermission` defaults to `false` before persistence.
|
||||
- `humanAvailable` is required explicitly.
|
||||
- `maxModelCalls` defaults to `20` before persistence.
|
||||
- `maxModelCalls`, when omitted, defaults at creation to the user's global
|
||||
model-call limit (`config/turn_limits.json`, built-in default `50`). The
|
||||
runtime makes no chat/headless distinction: the optional chat override is
|
||||
the chat UI's job — its send path passes an explicit `maxModelCalls`.
|
||||
Spawned sub-agents default to the global limit, which also caps the
|
||||
budget a parent may grant them. Settings changes affect only newly
|
||||
created turns.
|
||||
- Persisted values are fully resolved and immutable.
|
||||
|
||||
The capability is named `humanAvailable`, not `headless`. `headless` describes
|
||||
|
|
|
|||
|
|
@ -91,6 +91,24 @@ export function reset(): void {
|
|||
identifiedUserId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a PostHog feature flag for the current identity (rowboat user id
|
||||
* once identified, installation id before that). `defaultValue` is returned
|
||||
* when the flag can't be definitively evaluated — analytics disabled, flags
|
||||
* API unreachable, or the flag doesn't exist in the PostHog project.
|
||||
*/
|
||||
export async function isFeatureEnabled(key: string, defaultValue = false): Promise<boolean> {
|
||||
const ph = getClient();
|
||||
if (!ph) return defaultValue;
|
||||
try {
|
||||
const result = await ph.isFeatureEnabled(key, activeDistinctId());
|
||||
return result === undefined ? defaultValue : result;
|
||||
} catch (err) {
|
||||
console.error(`[Analytics] feature flag ${key} check failed:`, err);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (!client) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,28 @@ const countCache = new Map<string, { stars: number; at: number }>();
|
|||
|
||||
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
// Once GitHub says the hourly budget is spent (403/429 with
|
||||
// x-ratelimit-remaining: 0), every further request until the reset is a
|
||||
// guaranteed 403 — and the catalog re-requests counts on load and on every
|
||||
// search keystroke. Gate all fetches until the advertised reset instead of
|
||||
// burning requests on failures.
|
||||
let rateLimitedUntilMs = 0;
|
||||
|
||||
const isRateLimited = () => Date.now() < rateLimitedUntilMs;
|
||||
|
||||
function noteRateLimit(res: Response): void {
|
||||
if (res.status !== 403 && res.status !== 429) return;
|
||||
const remaining = res.headers.get('x-ratelimit-remaining');
|
||||
const resetSec = Number(res.headers.get('x-ratelimit-reset'));
|
||||
if (remaining === '0' && Number.isFinite(resetSec) && resetSec > 0) {
|
||||
rateLimitedUntilMs = Math.max(rateLimitedUntilMs, resetSec * 1000);
|
||||
} else {
|
||||
// Secondary rate limit / abuse detection — headers don't say when it
|
||||
// ends, so back off a few minutes.
|
||||
rateLimitedUntilMs = Math.max(rateLimitedUntilMs, Date.now() + 5 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function ghHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
|
|
@ -25,16 +47,19 @@ async function ghHeaders(): Promise<Record<string, string>> {
|
|||
export async function repoStars(repos: string[]): Promise<Record<string, number>> {
|
||||
const headers = await ghHeaders();
|
||||
const now = Date.now();
|
||||
const limited = isRateLimited();
|
||||
const out: Record<string, number> = {};
|
||||
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
||||
const cached = countCache.get(repo);
|
||||
if (cached && now - cached.at < COUNT_TTL_MS) {
|
||||
// While rate-limited, a stale count beats a guaranteed 403.
|
||||
if (cached && (limited || now - cached.at < COUNT_TTL_MS)) {
|
||||
out[repo] = cached.stars;
|
||||
return;
|
||||
}
|
||||
if (limited) return;
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}`, { headers });
|
||||
if (!res.ok) return; // deleted repo / rate limited — no count is fine
|
||||
if (!res.ok) { noteRateLimit(res); return; } // deleted repo / rate limited — no count is fine
|
||||
const body = await res.json() as { stargazers_count?: number };
|
||||
if (typeof body.stargazers_count === 'number') {
|
||||
countCache.set(repo, { stars: body.stargazers_count, at: now });
|
||||
|
|
@ -48,7 +73,7 @@ export async function repoStars(repos: string[]): Promise<Record<string, number>
|
|||
/** Which of these repos the signed-in user has starred. Empty when signed out. */
|
||||
export async function starredStatus(repos: string[]): Promise<Record<string, boolean>> {
|
||||
const auth = await getGithubToken();
|
||||
if (!auth) return {};
|
||||
if (!auth || isRateLimited()) return {};
|
||||
const headers = await ghHeaders();
|
||||
const out: Record<string, boolean> = {};
|
||||
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
||||
|
|
@ -56,6 +81,7 @@ export async function starredStatus(repos: string[]): Promise<Record<string, boo
|
|||
const res = await fetch(`https://api.github.com/user/starred/${repo}`, { headers });
|
||||
if (res.status === 204) out[repo] = true;
|
||||
else if (res.status === 404) out[repo] = false;
|
||||
else noteRateLimit(res);
|
||||
// other statuses (401 revoked token, 403 rate limit) → unknown, omit
|
||||
} catch { /* offline — unknown */ }
|
||||
}));
|
||||
|
|
@ -72,6 +98,7 @@ export async function setStar(repo: string, star: boolean): Promise<{ starred: b
|
|||
headers: { ...(await ghHeaders()), 'Content-Length': '0' },
|
||||
});
|
||||
if (res.status !== 204) {
|
||||
noteRateLimit(res);
|
||||
throw new Error(`star_failed: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
|
||||
}
|
||||
// Nudge the cached count so the UI reflects the action before the TTL expires.
|
||||
|
|
|
|||
352
apps/x/packages/core/src/auth/chatgpt-auth.test.ts
Normal file
352
apps/x/packages/core/src/auth/chatgpt-auth.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// Isolate the store from the real home dir BEFORE importing the module —
|
||||
// WorkDir is resolved at config.ts import time (same pattern as
|
||||
// classification_stamp.test.ts).
|
||||
const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'x-chatgpt-auth-test-'));
|
||||
process.env.ROWBOAT_WORKDIR = tmpWorkDir;
|
||||
|
||||
const chatgptAuth = await import('./chatgpt-auth.js');
|
||||
const {
|
||||
saveChatGPTTokens,
|
||||
getChatGPTAccessToken,
|
||||
getChatGPTStatus,
|
||||
signOutChatGPT,
|
||||
setTokenCipher,
|
||||
decodeJwtClaims,
|
||||
ChatGPTAuthRequiredError,
|
||||
} = chatgptAuth;
|
||||
const {
|
||||
CHATGPT_CLIENT_ID,
|
||||
CHATGPT_TOKEN_URL,
|
||||
CHATGPT_REVOKE_URL,
|
||||
CHATGPT_AUTH_CLAIM_NAMESPACE,
|
||||
CHATGPT_PROFILE_CLAIM_NAMESPACE,
|
||||
} = await import('./chatgpt-constants.js');
|
||||
|
||||
const AUTH_FILE = path.join(tmpWorkDir, 'config', 'chatgpt-auth.json');
|
||||
|
||||
// Fixed clock so expiry math is deterministic.
|
||||
const NOW_MS = new Date('2026-07-15T12:00:00Z').getTime();
|
||||
const NOW = Math.floor(NOW_MS / 1000);
|
||||
|
||||
/** Unsigned JWT with the given payload — decodeJwtClaims only reads part [1]. */
|
||||
function makeJwt(claims: Record<string, unknown>): string {
|
||||
const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
return `${b64({ alg: 'none' })}.${b64(claims)}.sig`;
|
||||
}
|
||||
|
||||
function makeIdToken(overrides: Record<string, unknown> = {}): string {
|
||||
return makeJwt({
|
||||
email: 'user@example.com',
|
||||
[CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_123' },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeAccessToken(expiresInSeconds: number, extra: Record<string, unknown> = {}): string {
|
||||
return makeJwt({ exp: NOW + expiresInSeconds, ...extra });
|
||||
}
|
||||
|
||||
/** Reversible fake cipher with a toggleable availability flag. */
|
||||
const fakeCipher = {
|
||||
available: true,
|
||||
isAvailable() { return this.available; },
|
||||
encrypt(plain: string) { return 'enc:' + Buffer.from(plain).toString('base64'); },
|
||||
decrypt(encrypted: string) {
|
||||
if (!encrypted.startsWith('enc:')) throw new Error('bad ciphertext');
|
||||
return Buffer.from(encrypted.slice(4), 'base64').toString('utf8');
|
||||
},
|
||||
};
|
||||
|
||||
function readStoredFile(): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW_MS);
|
||||
fakeCipher.available = true;
|
||||
setTokenCipher(fakeCipher);
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
fs.rmSync(AUTH_FILE, { force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpWorkDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('decodeJwtClaims', () => {
|
||||
it('decodes the payload with a pure base64url decode', () => {
|
||||
expect(decodeJwtClaims(makeJwt({ exp: 42, foo: 'bar' }))).toEqual({ exp: 42, foo: 'bar' });
|
||||
});
|
||||
|
||||
it('returns null for malformed input', () => {
|
||||
expect(decodeJwtClaims('not-a-jwt')).toBeNull();
|
||||
expect(decodeJwtClaims('a.!!!.c')).toBeNull();
|
||||
expect(decodeJwtClaims(`a.${Buffer.from('[1,2]').toString('base64url')}.c`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('token store', () => {
|
||||
it('persists tokens encrypted at rest and never writes token values in the clear', async () => {
|
||||
const accessToken = makeAccessToken(3600);
|
||||
const identity = await saveChatGPTTokens({
|
||||
accessToken,
|
||||
refreshToken: 'rt_secret_1',
|
||||
idToken: makeIdToken(),
|
||||
});
|
||||
|
||||
expect(identity).toEqual({ accountId: 'acct_123', email: 'user@example.com' });
|
||||
|
||||
const stored = readStoredFile();
|
||||
expect(stored.tokensEncrypted).toMatch(/^enc:/);
|
||||
expect(stored.tokens).toBeUndefined();
|
||||
expect(stored.plaintext).toBeUndefined();
|
||||
expect(stored.expiresAt).toBe(NOW + 3600);
|
||||
// No raw token material anywhere in the file.
|
||||
const raw = fs.readFileSync(AUTH_FILE, 'utf-8');
|
||||
expect(raw).not.toContain(accessToken);
|
||||
expect(raw).not.toContain('rt_secret_1');
|
||||
|
||||
await expect(getChatGPTStatus()).resolves.toEqual({
|
||||
signedIn: true,
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_123',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to plaintext with a marker when the cipher is unavailable', async () => {
|
||||
fakeCipher.available = false;
|
||||
await saveChatGPTTokens({
|
||||
accessToken: makeAccessToken(3600),
|
||||
refreshToken: 'rt_1',
|
||||
idToken: makeIdToken(),
|
||||
});
|
||||
|
||||
const stored = readStoredFile();
|
||||
expect(stored.tokensEncrypted).toBeUndefined();
|
||||
expect(stored.plaintext).toBe(true);
|
||||
expect((stored.tokens as { refreshToken: string }).refreshToken).toBe('rt_1');
|
||||
|
||||
await expect(getChatGPTAccessToken()).resolves.toBe(stored && (stored.tokens as { accessToken: string }).accessToken);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('parses accountId from the access token and email from the profile namespace as fallbacks', async () => {
|
||||
// No id_token; access token carries the auth namespace + profile email.
|
||||
await saveChatGPTTokens({
|
||||
accessToken: makeAccessToken(3600, {
|
||||
[CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_from_at' },
|
||||
[CHATGPT_PROFILE_CLAIM_NAMESPACE]: { email: 'profile@example.com' },
|
||||
}),
|
||||
refreshToken: 'rt_1',
|
||||
});
|
||||
|
||||
await expect(getChatGPTStatus()).resolves.toEqual({
|
||||
signedIn: true,
|
||||
email: 'profile@example.com',
|
||||
accountId: 'acct_from_at',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing identity when a refresh response carries no claims', async () => {
|
||||
await saveChatGPTTokens({
|
||||
accessToken: makeAccessToken(3600),
|
||||
refreshToken: 'rt_1',
|
||||
idToken: makeIdToken(),
|
||||
});
|
||||
// Rotated tokens with no identity claims at all.
|
||||
await saveChatGPTTokens({
|
||||
accessToken: makeAccessToken(7200),
|
||||
refreshToken: 'rt_2',
|
||||
});
|
||||
|
||||
await expect(getChatGPTStatus()).resolves.toEqual({
|
||||
signedIn: true,
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_123',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports signed out when nothing is stored', async () => {
|
||||
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
|
||||
await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChatGPTAccessToken', () => {
|
||||
it('returns the cached token without refreshing when >5 min from expiry', async () => {
|
||||
const accessToken = makeAccessToken(3600);
|
||||
await saveChatGPTTokens({ accessToken, refreshToken: 'rt_1', idToken: makeIdToken() });
|
||||
|
||||
await expect(getChatGPTAccessToken()).resolves.toBe(accessToken);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes within the 5-min window using the verified JSON request shape and persists rotation', async () => {
|
||||
await saveChatGPTTokens({
|
||||
accessToken: makeAccessToken(60), // inside the 5-min margin
|
||||
refreshToken: 'rt_old',
|
||||
idToken: makeIdToken(),
|
||||
});
|
||||
|
||||
const newAccessToken = makeAccessToken(3600);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({
|
||||
access_token: newAccessToken,
|
||||
refresh_token: 'rt_new',
|
||||
}));
|
||||
|
||||
await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken);
|
||||
|
||||
// Request shape verified against codex-rs/login/src/auth/manager.rs.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(CHATGPT_TOKEN_URL);
|
||||
expect(init.method).toBe('POST');
|
||||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
client_id: CHATGPT_CLIENT_ID,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'rt_old',
|
||||
});
|
||||
|
||||
// Rotated material persisted (decrypt the file with the fake cipher).
|
||||
const stored = readStoredFile();
|
||||
const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record<string, string>;
|
||||
expect(material.accessToken).toBe(newAccessToken);
|
||||
expect(material.refreshToken).toBe('rt_new');
|
||||
expect(stored.expiresAt).toBe(NOW + 3600);
|
||||
|
||||
// Now fresh — no second refresh.
|
||||
await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the old refresh token when the response omits one', async () => {
|
||||
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_keep' });
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: makeAccessToken(3600) }));
|
||||
|
||||
await getChatGPTAccessToken();
|
||||
|
||||
const stored = readStoredFile();
|
||||
const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record<string, string>;
|
||||
expect(material.refreshToken).toBe('rt_keep');
|
||||
});
|
||||
|
||||
it('shares a single in-flight refresh across concurrent callers', async () => {
|
||||
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' });
|
||||
|
||||
const newAccessToken = makeAccessToken(3600);
|
||||
let release!: (r: Response) => void;
|
||||
fetchMock.mockReturnValueOnce(new Promise<Response>((resolve) => { release = resolve; }));
|
||||
|
||||
const a = getChatGPTAccessToken();
|
||||
const b = getChatGPTAccessToken();
|
||||
// Let both callers reach the refresh gate before releasing the response.
|
||||
await Promise.resolve();
|
||||
release(jsonResponse({ access_token: newAccessToken }));
|
||||
|
||||
await expect(a).resolves.toBe(newAccessToken);
|
||||
await expect(b).resolves.toBe(newAccessToken);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears to a clean signed-out state and throws the typed error when the refresh token is rejected', async () => {
|
||||
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_revoked' });
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 401));
|
||||
|
||||
await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
|
||||
expect(fs.existsSync(AUTH_FILE)).toBe(false);
|
||||
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
|
||||
});
|
||||
|
||||
it('treats 5xx as transient: throws a plain error and keeps the stored tokens', async () => {
|
||||
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1', idToken: makeIdToken() });
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'server_error' }, 503));
|
||||
|
||||
const err = await getChatGPTAccessToken().catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError);
|
||||
await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true });
|
||||
});
|
||||
|
||||
it('treats network failure as transient and keeps the stored tokens', async () => {
|
||||
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' });
|
||||
fetchMock.mockRejectedValueOnce(new Error('fetch failed'));
|
||||
|
||||
const err = await getChatGPTAccessToken().catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError);
|
||||
await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true });
|
||||
});
|
||||
|
||||
it('clears the store and requires sign-in when decryption fails', async () => {
|
||||
await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' });
|
||||
const stored = readStoredFile();
|
||||
stored.tokensEncrypted = 'corrupted';
|
||||
fs.writeFileSync(AUTH_FILE, JSON.stringify(stored));
|
||||
|
||||
await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
|
||||
expect(fs.existsSync(AUTH_FILE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signOutChatGPT', () => {
|
||||
it('best-effort revokes the refresh token (with client_id) then clears the store', async () => {
|
||||
await saveChatGPTTokens({
|
||||
accessToken: makeAccessToken(3600),
|
||||
refreshToken: 'rt_1',
|
||||
idToken: makeIdToken(),
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}));
|
||||
|
||||
await signOutChatGPT();
|
||||
|
||||
// Revoke shape verified against codex-rs/login/src/auth/revoke.rs.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(CHATGPT_REVOKE_URL);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
token: 'rt_1',
|
||||
token_type_hint: 'refresh_token',
|
||||
client_id: CHATGPT_CLIENT_ID,
|
||||
});
|
||||
|
||||
expect(fs.existsSync(AUTH_FILE)).toBe(false);
|
||||
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
|
||||
});
|
||||
|
||||
it('still clears the store when revocation fails', async () => {
|
||||
await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' });
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
await signOutChatGPT();
|
||||
|
||||
expect(fs.existsSync(AUTH_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op-safe local clear when nothing is stored', async () => {
|
||||
await signOutChatGPT();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
|
||||
});
|
||||
});
|
||||
389
apps/x/packages/core/src/auth/chatgpt-auth.ts
Normal file
389
apps/x/packages/core/src/auth/chatgpt-auth.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import {
|
||||
CHATGPT_AUTH_CLAIM_NAMESPACE,
|
||||
CHATGPT_CLIENT_ID,
|
||||
CHATGPT_PROFILE_CLAIM_NAMESPACE,
|
||||
CHATGPT_REDIRECT_URI,
|
||||
CHATGPT_REFRESH_MARGIN_SECONDS,
|
||||
CHATGPT_REVOKE_URL,
|
||||
CHATGPT_TOKEN_URL,
|
||||
} from './chatgpt-constants.js';
|
||||
|
||||
// "Sign in with ChatGPT" token layer. Owns storage + refresh of the OAuth
|
||||
// tokens acquired via the Codex CLI client (see chatgpt-constants.ts). The
|
||||
// interactive sign-in flow (PKCE + loopback server on 127.0.0.1:1455) lives
|
||||
// in the Electron main process and lands in Phase 2; it persists tokens here
|
||||
// via saveChatGPTTokens(). Consumers (the Codex Responses model client) must
|
||||
// go through getChatGPTAccessToken() and never read the store directly.
|
||||
//
|
||||
// IMPORTANT: never log token values — log events only.
|
||||
|
||||
const AUTH_FILE = path.join(WorkDir, 'config', 'chatgpt-auth.json');
|
||||
|
||||
// Token-at-rest encryption is provided by the Electron main process
|
||||
// (safeStorage) — core stays electron-free. When no cipher is wired (or the
|
||||
// OS keychain is unavailable) tokens are stored plaintext with a marker,
|
||||
// matching the existing GitHub token storage in apps/github-auth.ts.
|
||||
export interface TokenCipher {
|
||||
isAvailable(): boolean;
|
||||
encrypt(plain: string): string; // returns base64
|
||||
decrypt(encrypted: string): string;
|
||||
}
|
||||
let cipher: TokenCipher | null = null;
|
||||
export function setTokenCipher(c: TokenCipher): void {
|
||||
cipher = c;
|
||||
}
|
||||
|
||||
/** The sensitive material — stored encrypted when the cipher is available. */
|
||||
type TokenMaterial = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type StoredChatGPTAuth = {
|
||||
/** ChatGPT account id, parsed from the id_token (see extractIdentity). */
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
/** Unix seconds — the access token's `exp` claim. */
|
||||
expiresAt: number;
|
||||
createdAt: string;
|
||||
/** base64 ciphertext of JSON TokenMaterial, via the injected cipher. */
|
||||
tokensEncrypted?: string;
|
||||
/** Plaintext fallback when no cipher/keychain is available. */
|
||||
tokens?: TokenMaterial;
|
||||
plaintext?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown when there is no usable ChatGPT session — never signed in, refresh
|
||||
* token revoked/expired, or the stored tokens are unreadable. Callers should
|
||||
* surface "Sign in with ChatGPT" and must not retry.
|
||||
*/
|
||||
export class ChatGPTAuthRequiredError extends Error {
|
||||
constructor(message = 'ChatGPT sign-in required') {
|
||||
super(message);
|
||||
this.name = 'ChatGPTAuthRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT's payload claims with a pure base64url decode — no signature
|
||||
* verification. Fine for our use: we only mine identity/expiry hints from
|
||||
* tokens we received directly from the token endpoint over TLS.
|
||||
*/
|
||||
export function decodeJwtClaims(jwt: string): Record<string, unknown> | null {
|
||||
const parts = jwt.split('.');
|
||||
if (parts.length < 2 || !parts[1]) return null;
|
||||
try {
|
||||
const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
|
||||
const parsed: unknown = JSON.parse(payload);
|
||||
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claimString(source: unknown, key: string): string | undefined {
|
||||
if (source !== null && typeof source === 'object') {
|
||||
const value = (source as Record<string, unknown>)[key];
|
||||
if (typeof value === 'string' && value.length > 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity claims, per codex-rs/login/src/token_data.rs:
|
||||
* - accountId: `chatgpt_account_id` inside "https://api.openai.com/auth"
|
||||
* - email: root `email` claim, falling back to `email` inside
|
||||
* "https://api.openai.com/profile"
|
||||
* We check the id_token first (codex parses identity from it), then the
|
||||
* access token, which carries the same auth claim namespace.
|
||||
*/
|
||||
function extractIdentity(tokens: Array<string | undefined>): { accountId?: string; email?: string } {
|
||||
let accountId: string | undefined;
|
||||
let email: string | undefined;
|
||||
for (const token of tokens) {
|
||||
if (!token) continue;
|
||||
const claims = decodeJwtClaims(token);
|
||||
if (!claims) continue;
|
||||
accountId ??= claimString(claims[CHATGPT_AUTH_CLAIM_NAMESPACE], 'chatgpt_account_id');
|
||||
email ??= claimString(claims, 'email')
|
||||
?? claimString(claims[CHATGPT_PROFILE_CLAIM_NAMESPACE], 'email');
|
||||
}
|
||||
return { accountId, email };
|
||||
}
|
||||
|
||||
async function readAuth(): Promise<StoredChatGPTAuth | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredChatGPTAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAuth(auth: StoredChatGPTAuth): Promise<void> {
|
||||
await fs.mkdir(path.dirname(AUTH_FILE), { recursive: true });
|
||||
await fs.writeFile(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
async function clearStore(): Promise<void> {
|
||||
await fs.rm(AUTH_FILE, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the sensitive token material from a stored entry. Returns null when it
|
||||
* cannot be read; a failed DECRYPT additionally clears the store (keychain
|
||||
* changed / corrupt ciphertext is unrecoverable — force a clean re-sign-in,
|
||||
* mirroring the GitHub token path).
|
||||
*/
|
||||
async function getTokenMaterial(auth: StoredChatGPTAuth): Promise<TokenMaterial | null> {
|
||||
if (auth.tokensEncrypted && cipher?.isAvailable()) {
|
||||
try {
|
||||
const material = JSON.parse(cipher.decrypt(auth.tokensEncrypted)) as TokenMaterial;
|
||||
if (typeof material.accessToken === 'string' && typeof material.refreshToken === 'string') {
|
||||
return material;
|
||||
}
|
||||
throw new Error('malformed token material');
|
||||
} catch {
|
||||
console.warn('[ChatGPTAuth] Failed to decrypt stored tokens; clearing auth');
|
||||
await clearStore();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (auth.tokens) return auth.tokens;
|
||||
if (auth.tokensEncrypted) {
|
||||
// Encrypted store but no cipher wired (e.g. keychain unavailable this
|
||||
// launch). Don't clear — the tokens may become readable again.
|
||||
console.warn('[ChatGPTAuth] Stored tokens are encrypted but no cipher is available');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist tokens (called by the Phase 2 sign-in flow and by refresh).
|
||||
* Derives expiry from the access token's `exp` claim and identity from the
|
||||
* id_token / access token claims; existing identity is preserved when a
|
||||
* refresh response doesn't carry the claims.
|
||||
*/
|
||||
export async function saveChatGPTTokens(input: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken?: string;
|
||||
}): Promise<{ accountId?: string; email?: string }> {
|
||||
const existing = await readAuth();
|
||||
|
||||
const identity = extractIdentity([input.idToken, input.accessToken]);
|
||||
const accountId = identity.accountId ?? existing?.accountId;
|
||||
const email = identity.email ?? existing?.email;
|
||||
|
||||
const exp = decodeJwtClaims(input.accessToken)?.exp;
|
||||
let expiresAt: number;
|
||||
if (typeof exp === 'number') {
|
||||
expiresAt = exp;
|
||||
} else {
|
||||
// The token endpoint returns no expires_in (verified against
|
||||
// codex-rs/login/src/auth/manager.rs) — the JWT exp claim is the only
|
||||
// expiry source. Without it, assume a conservative 1h lifetime.
|
||||
console.warn('[ChatGPTAuth] Access token has no exp claim; assuming 1h lifetime');
|
||||
expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||
}
|
||||
|
||||
const auth: StoredChatGPTAuth = {
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(email ? { email } : {}),
|
||||
expiresAt,
|
||||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
const material: TokenMaterial = {
|
||||
accessToken: input.accessToken,
|
||||
refreshToken: input.refreshToken,
|
||||
};
|
||||
if (cipher?.isAvailable()) {
|
||||
auth.tokensEncrypted = cipher.encrypt(JSON.stringify(material));
|
||||
} else {
|
||||
auth.tokens = material;
|
||||
auth.plaintext = true;
|
||||
}
|
||||
await writeAuth(auth);
|
||||
return { accountId, email };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for tokens and persist them (called by the
|
||||
* main process sign-in flow after the loopback callback). Request/response
|
||||
* shape verified against codex-rs/login/src/server.rs
|
||||
* (`exchange_code_for_tokens`): form-encoded POST — unlike the JSON refresh —
|
||||
* response `{ id_token, access_token, refresh_token }`, no expires_in.
|
||||
* Returns the parsed identity for the caller to surface.
|
||||
*/
|
||||
export async function exchangeChatGPTCode(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
): Promise<{ accountId?: string; email?: string }> {
|
||||
const res = await fetch(CHATGPT_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: CHATGPT_REDIRECT_URI,
|
||||
client_id: CHATGPT_CLIENT_ID,
|
||||
code_verifier: codeVerifier,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`ChatGPT token exchange failed: HTTP ${res.status}`);
|
||||
}
|
||||
const body = await res.json() as {
|
||||
id_token?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
if (!body.access_token || !body.refresh_token) {
|
||||
throw new Error('ChatGPT token exchange response is missing tokens');
|
||||
}
|
||||
const identity = await saveChatGPTTokens({
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
...(body.id_token ? { idToken: body.id_token } : {}),
|
||||
});
|
||||
console.log('[ChatGPTAuth] Sign-in token exchange complete');
|
||||
return identity;
|
||||
}
|
||||
|
||||
// Single-flight refresh: concurrent expired-token callers share one request
|
||||
// (same pattern as the Rowboat gateway token in auth/tokens.ts). One refresh
|
||||
// owner matters here — parallel refreshes racing on one refresh_token can
|
||||
// invalidate each other's grant.
|
||||
let refreshInFlight: Promise<string> | null = null;
|
||||
|
||||
async function performRefresh(refreshToken: string): Promise<string> {
|
||||
// Request/response shape verified against codex-rs/login/src/auth/manager.rs:
|
||||
// JSON body (not form-encoded), response may omit refresh_token (keep the
|
||||
// old one) and never carries expires_in.
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(CHATGPT_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: CHATGPT_CLIENT_ID,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch (error) {
|
||||
// Network failure: transient — keep the stored tokens so the next
|
||||
// call retries instead of forcing a re-sign-in.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`ChatGPT token refresh failed: ${message}`);
|
||||
}
|
||||
|
||||
if (res.status === 400 || res.status === 401) {
|
||||
// Refresh token revoked or expired — unrecoverable without the user.
|
||||
console.log(`[ChatGPTAuth] Refresh rejected (HTTP ${res.status}); signing out`);
|
||||
await clearStore();
|
||||
throw new ChatGPTAuthRequiredError('ChatGPT session expired. Please sign in again.');
|
||||
}
|
||||
if (!res.ok) {
|
||||
// 5xx / rate limit: transient — keep the stored tokens.
|
||||
throw new Error(`ChatGPT token refresh failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const body = await res.json() as {
|
||||
id_token?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
if (!body.access_token) {
|
||||
throw new Error('ChatGPT token refresh returned no access token');
|
||||
}
|
||||
|
||||
await saveChatGPTTokens({
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token || refreshToken,
|
||||
...(body.id_token ? { idToken: body.id_token } : {}),
|
||||
});
|
||||
console.log('[ChatGPTAuth] Access token refreshed');
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one seam for consumers (the Codex Responses model client): returns a
|
||||
* valid access token, transparently refreshing when within 5 minutes of
|
||||
* expiry. Throws ChatGPTAuthRequiredError when there is no usable session.
|
||||
*/
|
||||
export async function getChatGPTAccessToken(): Promise<string> {
|
||||
const auth = await readAuth();
|
||||
if (!auth) {
|
||||
throw new ChatGPTAuthRequiredError();
|
||||
}
|
||||
const material = await getTokenMaterial(auth);
|
||||
if (!material) {
|
||||
throw new ChatGPTAuthRequiredError();
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (auth.expiresAt - now > CHATGPT_REFRESH_MARGIN_SECONDS) {
|
||||
return material.accessToken;
|
||||
}
|
||||
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = performRefresh(material.refreshToken).finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
/** Connection state for the UI. Never returns token values. */
|
||||
export async function getChatGPTStatus(): Promise<{ signedIn: boolean; email?: string; accountId?: string }> {
|
||||
const auth = await readAuth();
|
||||
if (!auth || (!auth.tokensEncrypted && !auth.tokens)) {
|
||||
return { signedIn: false };
|
||||
}
|
||||
return {
|
||||
signedIn: true,
|
||||
...(auth.email ? { email: auth.email } : {}),
|
||||
...(auth.accountId ? { accountId: auth.accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out: best-effort revocation at auth.openai.com (endpoint + request
|
||||
* shape verified in codex-rs/login/src/auth/revoke.rs — refresh token first,
|
||||
* with client_id; access token as fallback, without), then clear the local
|
||||
* store. Revocation failure never blocks the local sign-out.
|
||||
*/
|
||||
export async function signOutChatGPT(): Promise<void> {
|
||||
const auth = await readAuth();
|
||||
if (auth) {
|
||||
const material = await getTokenMaterial(auth);
|
||||
if (material) {
|
||||
const body = material.refreshToken
|
||||
? { token: material.refreshToken, token_type_hint: 'refresh_token', client_id: CHATGPT_CLIENT_ID }
|
||||
: { token: material.accessToken, token_type_hint: 'access_token' };
|
||||
try {
|
||||
const res = await fetch(CHATGPT_REVOKE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[ChatGPTAuth] Token revocation returned HTTP ${res.status}; continuing with local sign-out`);
|
||||
}
|
||||
} catch {
|
||||
console.warn('[ChatGPTAuth] Token revocation failed; continuing with local sign-out');
|
||||
}
|
||||
}
|
||||
}
|
||||
await clearStore();
|
||||
console.log('[ChatGPTAuth] Signed out');
|
||||
}
|
||||
97
apps/x/packages/core/src/auth/chatgpt-constants.ts
Normal file
97
apps/x/packages/core/src/auth/chatgpt-constants.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// OAuth constants for "Sign in with ChatGPT" (ChatGPT subscription auth).
|
||||
//
|
||||
// Rowboat authenticates against OpenAI's auth server using the SAME public
|
||||
// client the open-source Codex CLI uses. Every value below was verified
|
||||
// against the openai/codex sources on 2026-07-15 — do not edit from memory;
|
||||
// re-check the linked files instead.
|
||||
//
|
||||
// Sources (github.com/openai/codex, main branch):
|
||||
// - codex-rs/login/src/auth/manager.rs → CLIENT_ID, refresh request shape,
|
||||
// 5-minute access-token refresh window
|
||||
// - codex-rs/login/src/server.rs → DEFAULT_ISSUER, /oauth/authorize,
|
||||
// /oauth/token, DEFAULT_PORT 1455, /auth/callback, scopes, extra
|
||||
// authorize params
|
||||
// - codex-rs/login/src/auth/revoke.rs → /oauth/revoke request shape
|
||||
// - codex-rs/login/src/token_data.rs → JWT claim names for account id
|
||||
// and email
|
||||
|
||||
/**
|
||||
* OAuth client id of the official Codex CLI.
|
||||
* Source: codex-rs/login/src/auth/manager.rs (`pub const CLIENT_ID`).
|
||||
* Client ids are public identifiers, not secrets.
|
||||
*/
|
||||
export const CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
|
||||
/** Source: codex-rs/login/src/server.rs (`DEFAULT_ISSUER`). */
|
||||
export const CHATGPT_ISSUER = 'https://auth.openai.com';
|
||||
|
||||
/** Source: codex-rs/login/src/server.rs (`build_authorize_url`: `{issuer}/oauth/authorize`). */
|
||||
export const CHATGPT_AUTHORIZE_URL = `${CHATGPT_ISSUER}/oauth/authorize`;
|
||||
|
||||
/**
|
||||
* Token endpoint, used for both code exchange and refresh.
|
||||
* Source: codex-rs/login/src/server.rs (`exchange_code_for_tokens`) and
|
||||
* codex-rs/login/src/auth/manager.rs (refresh POSTs here).
|
||||
*
|
||||
* NOTE (verified in manager.rs): the refresh request is a JSON body —
|
||||
* `{ client_id, grant_type: "refresh_token", refresh_token }` with
|
||||
* Content-Type application/json, NOT the form-encoded body standard OAuth
|
||||
* clients send. The response is `{ id_token?, access_token?, refresh_token? }`
|
||||
* and carries NO `expires_in` — expiry must be read from the new access
|
||||
* token's `exp` claim.
|
||||
*/
|
||||
export const CHATGPT_TOKEN_URL = `${CHATGPT_ISSUER}/oauth/token`;
|
||||
|
||||
/**
|
||||
* Revocation endpoint. Source: codex-rs/login/src/auth/revoke.rs — JSON POST
|
||||
* `{ token, token_type_hint: "refresh_token"|"access_token", client_id }`
|
||||
* (client_id only when revoking a refresh token), 10s timeout.
|
||||
*/
|
||||
export const CHATGPT_REVOKE_URL = `${CHATGPT_ISSUER}/oauth/revoke`;
|
||||
|
||||
/**
|
||||
* The loopback callback the Codex client id is registered for. The port is
|
||||
* fixed — unlike Rowboat's DCR providers there is no scan-to-next-port
|
||||
* fallback, because the redirect URI is pre-registered at OpenAI.
|
||||
* Source: codex-rs/login/src/server.rs (`DEFAULT_PORT: u16 = 1455`,
|
||||
* `http://localhost:{port}/auth/callback`).
|
||||
*/
|
||||
export const CHATGPT_CALLBACK_PORT = 1455;
|
||||
export const CHATGPT_CALLBACK_PATH = '/auth/callback';
|
||||
export const CHATGPT_REDIRECT_URI = `http://localhost:${CHATGPT_CALLBACK_PORT}${CHATGPT_CALLBACK_PATH}`;
|
||||
|
||||
/**
|
||||
* Scopes to request at authorize time (Phase 2).
|
||||
* Source: codex-rs/login/src/server.rs requests
|
||||
* "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
||||
* we deliberately drop the two `api.connectors.*` scopes — Rowboat only needs
|
||||
* identity + refresh (`offline_access`) for model calls, matching what Zed's
|
||||
* ChatGPT provider requests.
|
||||
*/
|
||||
export const CHATGPT_SCOPES = ['openid', 'profile', 'email', 'offline_access'];
|
||||
|
||||
/**
|
||||
* Extra authorize-URL query params the Codex flow sends (Phase 2).
|
||||
* Source: codex-rs/login/src/server.rs (`build_authorize_url`).
|
||||
*/
|
||||
export const CHATGPT_EXTRA_AUTHORIZE_PARAMS: Record<string, string> = {
|
||||
id_token_add_organizations: 'true',
|
||||
codex_cli_simplified_flow: 'true',
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh the access token when it is within this margin of expiry.
|
||||
* Source: codex-rs/login/src/auth/manager.rs
|
||||
* (`CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5`).
|
||||
*/
|
||||
export const CHATGPT_REFRESH_MARGIN_SECONDS = 5 * 60;
|
||||
|
||||
/**
|
||||
* JWT claim namespaces. Source: codex-rs/login/src/token_data.rs — the
|
||||
* ChatGPT account id is `chatgpt_account_id` inside the
|
||||
* "https://api.openai.com/auth" claim of the id_token; email is the root
|
||||
* `email` claim with a fallback to `email` inside
|
||||
* "https://api.openai.com/profile".
|
||||
*/
|
||||
export const CHATGPT_AUTH_CLAIM_NAMESPACE = 'https://api.openai.com/auth';
|
||||
export const CHATGPT_PROFILE_CLAIM_NAMESPACE = 'https://api.openai.com/profile';
|
||||
18
apps/x/packages/core/src/auth/jwt.ts
Normal file
18
apps/x/packages/core/src/auth/jwt.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Decode a JWT's payload segment without verifying the signature — for
|
||||
* reading claims out of tokens we already trust (or only use as local cache
|
||||
* keys), never for authentication decisions.
|
||||
*/
|
||||
export function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
|
||||
const parsed = JSON.parse(json);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,10 @@ Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}i
|
|||
|
||||
const runningTasks = new Set<string>();
|
||||
|
||||
type RunAnalyticsOutcome =
|
||||
| { event: 'bg_agent_run_completed'; properties: { trigger: BackgroundTaskTriggerType } }
|
||||
| { event: 'bg_agent_run_failed'; properties: { trigger: BackgroundTaskTriggerType; error: string } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -114,6 +118,8 @@ export async function runBackgroundTask(
|
|||
}
|
||||
runningTasks.add(slug);
|
||||
|
||||
let analyticsOutcome: RunAnalyticsOutcome | undefined;
|
||||
|
||||
try {
|
||||
const task = await fetchTask(slug);
|
||||
if (!task) {
|
||||
|
|
@ -211,7 +217,6 @@ export async function runBackgroundTask(
|
|||
});
|
||||
|
||||
log.log(`${slug} — done summary="${truncate(summary)}"`);
|
||||
capture('bg_agent_run_completed', { trigger });
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
type: 'background_task_agent_complete',
|
||||
|
|
@ -220,10 +225,16 @@ export async function runBackgroundTask(
|
|||
...(summary ? { summary } : {}),
|
||||
});
|
||||
|
||||
analyticsOutcome = { event: 'bg_agent_run_completed', properties: { trigger } };
|
||||
return { slug, runId, summary };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
analyticsOutcome = {
|
||||
event: 'bg_agent_run_failed',
|
||||
properties: { trigger, error: msg },
|
||||
};
|
||||
|
||||
// Failure — only record the error. `lastRunAt` and `lastRunSummary`
|
||||
// are deliberately untouched so the user keeps seeing the last good
|
||||
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
|
||||
|
|
@ -235,7 +246,6 @@ export async function runBackgroundTask(
|
|||
}
|
||||
|
||||
log.log(`${slug} — failed: ${truncate(msg)}`);
|
||||
capture('bg_agent_run_failed', { trigger });
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
type: 'background_task_agent_complete',
|
||||
|
|
@ -246,7 +256,22 @@ export async function runBackgroundTask(
|
|||
|
||||
return { slug, runId, summary: null, error: msg };
|
||||
}
|
||||
} catch (err) {
|
||||
// Preserve the original throw behavior for setup/infrastructure errors,
|
||||
// but still settle analytics for the attempted run. If the agent had
|
||||
// already failed, keep that original failure reason.
|
||||
analyticsOutcome ??= {
|
||||
event: 'bg_agent_run_failed',
|
||||
properties: {
|
||||
trigger,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
throw err;
|
||||
} finally {
|
||||
if (analyticsOutcome) {
|
||||
capture(analyticsOutcome.event, analyticsOutcome.properties);
|
||||
}
|
||||
runningTasks.delete(slug);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
availableCredits: number;
|
||||
usageDay: string;
|
||||
};
|
||||
// credit-store bucket; absent on API deployments that predate grants
|
||||
store?: {
|
||||
availableCredits: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -45,5 +49,8 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
catalog: config.billing,
|
||||
monthly: body.billing.usage.monthly,
|
||||
daily: body.billing.usage.daily,
|
||||
store: {
|
||||
availableCredits: body.billing.usage.store?.availableCredits ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
284
apps/x/packages/core/src/billing/credits.test.ts
Normal file
284
apps/x/packages/core/src/billing/credits.test.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// A JWT whose payload decodes to { sub: "user-1" } (signature irrelevant —
|
||||
// the service only reads the sub claim as a local cache key).
|
||||
function fakeJwt(sub: string): string {
|
||||
const payload = Buffer.from(JSON.stringify({ sub }), "utf-8").toString("base64url");
|
||||
return `header.${payload}.sig`;
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
const CATALOG = {
|
||||
plans: [
|
||||
{ id: "free-1", category: "free", displayName: "Free", monthlyCredits: 0, dailyCredits: 0, monthlyPriceCents: null },
|
||||
{ id: "pro-1", category: "pro", displayName: "Pro", monthlyCredits: 0, dailyCredits: 0, monthlyPriceCents: 2000 },
|
||||
],
|
||||
};
|
||||
|
||||
// the activation catalog as served by GET /v1/config — display metadata is
|
||||
// backend-owned; the app only knows the codes
|
||||
const ACTIVATIONS = [
|
||||
{ code: "first_gmail_connected", displayName: "Connected Gmail", description: "Link your Google account", credits: 100 },
|
||||
{ code: "first_email_sent", displayName: "First email sent", credits: 100 },
|
||||
{ code: "first_bg_agent", displayName: "First background agent", credits: 100 },
|
||||
{ code: "first_app_built", displayName: "First app built", credits: 100 },
|
||||
{ code: "some_future_code", displayName: "Unknown to this app version", credits: 100 },
|
||||
];
|
||||
|
||||
function mockBilling(planId: string | null) {
|
||||
vi.doMock("./billing.js", () => ({
|
||||
getBillingInfo: vi.fn(async () => ({ subscriptionPlanId: planId, catalog: CATALOG })),
|
||||
}));
|
||||
}
|
||||
|
||||
// pass null to omit the creditActivations field entirely (pre-catalog API)
|
||||
function mockConfig(creditActivations: unknown[] | null = ACTIVATIONS) {
|
||||
vi.doMock("../config/rowboat.js", () => ({
|
||||
getRowboatConfig: vi.fn(async () => ({
|
||||
appUrl: "https://app.example",
|
||||
websocketApiUrl: "",
|
||||
supabaseUrl: "",
|
||||
billing: CATALOG,
|
||||
...(creditActivations ? { creditActivations } : {}),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rowboat-credits-test-"));
|
||||
process.env.ROWBOAT_WORKDIR = tmpDir;
|
||||
// enable the feature flag via the dev override (no PostHog in tests)
|
||||
process.env.ROWBOAT_CREDITS = "1";
|
||||
vi.resetModules();
|
||||
vi.doMock("../account/account.js", () => ({
|
||||
isSignedIn: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock("../auth/tokens.js", () => ({
|
||||
getAccessToken: vi.fn(async () => fakeJwt("user-1")),
|
||||
}));
|
||||
mockBilling("free-1");
|
||||
mockConfig();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ROWBOAT_WORKDIR;
|
||||
delete process.env.ROWBOAT_CREDITS;
|
||||
vi.doUnmock("../account/account.js");
|
||||
vi.doUnmock("../auth/tokens.js");
|
||||
vi.doUnmock("./billing.js");
|
||||
vi.doUnmock("../config/rowboat.js");
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function loadCredits() {
|
||||
return import("./credits.js");
|
||||
}
|
||||
|
||||
function grantResponse(amount: number) {
|
||||
return new Response(JSON.stringify({ grant: { amount } }), { status: 200 });
|
||||
}
|
||||
|
||||
describe("maybeActivateCredit", () => {
|
||||
it("activates once, notifies subscribers, and skips repeat attempts locally", async () => {
|
||||
const credits = await loadCredits();
|
||||
const seen: unknown[] = [];
|
||||
credits.subscribeCreditActivations((event) => seen.push(event));
|
||||
fetchMock.mockResolvedValueOnce(grantResponse(100));
|
||||
|
||||
const first = await credits.maybeActivateCredit("first_email_sent");
|
||||
// display name comes from the API catalog, not app code
|
||||
expect(first).toMatchObject({ code: "first_email_sent", title: "First email sent", credits: 100 });
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
// second attempt short-circuits on the local claim — no network call
|
||||
const second = await credits.maybeActivateCredit("first_email_sent");
|
||||
expect(second).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.activities.find((a) => a.code === "first_email_sent")?.claimed).toBe(true);
|
||||
expect(state.activities.find((a) => a.code === "first_bg_agent")?.claimed).toBe(false);
|
||||
// catalog codes this app version doesn't know are dropped
|
||||
expect(state.activities.map((a) => a.code)).not.toContain("some_future_code");
|
||||
});
|
||||
|
||||
it("shows no activities when the API serves no reward catalog", async () => {
|
||||
mockConfig(null);
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(true);
|
||||
expect(state.activities).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats 409 as already claimed without notifying", async () => {
|
||||
const credits = await loadCredits();
|
||||
const seen: unknown[] = [];
|
||||
credits.subscribeCreditActivations((event) => seen.push(event));
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Already activated" }), { status: 409 }));
|
||||
|
||||
expect(await credits.maybeActivateCredit("first_gmail_connected")).toBeNull();
|
||||
expect(seen).toHaveLength(0);
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.activities.find((a) => a.code === "first_gmail_connected")?.claimed).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the code unclaimed on 404/network failure so the next action retries", async () => {
|
||||
const credits = await loadCredits();
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Unknown activation code" }), { status: 404 }));
|
||||
expect(await credits.maybeActivateCredit("first_app_built")).toBeNull();
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
expect(await credits.maybeActivateCredit("first_app_built")).toBeNull();
|
||||
|
||||
// both failures left it retryable — a later attempt succeeds
|
||||
fetchMock.mockResolvedValueOnce(grantResponse(100));
|
||||
expect(await credits.maybeActivateCredit("first_app_built")).toMatchObject({ credits: 100 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("is enabled by default when no flag source is available", async () => {
|
||||
// no env override, no PostHog client in tests -> defaults on
|
||||
delete process.env.ROWBOAT_CREDITS;
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing when the feature flag is off", async () => {
|
||||
process.env.ROWBOAT_CREDITS = "0";
|
||||
const credits = await loadCredits();
|
||||
expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing for paid plans — free tier only", async () => {
|
||||
mockBilling("pro-1");
|
||||
const credits = await loadCredits();
|
||||
expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when signed out", async () => {
|
||||
vi.doMock("../account/account.js", () => ({
|
||||
isSignedIn: vi.fn(async () => false),
|
||||
}));
|
||||
const credits = await loadCredits();
|
||||
expect(await credits.maybeActivateCredit("first_email_sent")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(false);
|
||||
expect(state.activities.every((a) => !a.claimed)).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes claims to the signed-in user", async () => {
|
||||
const credits = await loadCredits();
|
||||
fetchMock.mockResolvedValueOnce(grantResponse(100));
|
||||
await credits.maybeActivateCredit("first_email_sent");
|
||||
|
||||
// switch accounts: same install, different sub
|
||||
vi.doMock("../auth/tokens.js", () => ({
|
||||
getAccessToken: vi.fn(async () => fakeJwt("user-2")),
|
||||
}));
|
||||
vi.resetModules();
|
||||
const creditsUser2 = await loadCredits();
|
||||
const state = await creditsUser2.getCreditsState();
|
||||
expect(state.activities.find((a) => a.code === "first_email_sent")?.claimed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const REFERRAL_STATUS = {
|
||||
code: "ABC-DEF-GHJ",
|
||||
claimsUsed: 1,
|
||||
maxClaims: 3,
|
||||
referrerCredits: 500,
|
||||
refereeCredits: 500,
|
||||
};
|
||||
|
||||
// route fetches by URL: GET /v1/referral -> status, POST /v1/referral/claims -> claim
|
||||
function mockReferralApi(claim: () => Response, status: () => Response = () => new Response(JSON.stringify(REFERRAL_STATUS), { status: 200 })) {
|
||||
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).endsWith("/v1/referral")) return status();
|
||||
if (String(url).endsWith("/v1/referral/claims") && init?.method === "POST") return claim();
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("referrals", () => {
|
||||
it("includes the referral status in state", async () => {
|
||||
mockReferralApi(() => new Response(null, { status: 500 }));
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.referral).toEqual({ ...REFERRAL_STATUS, claimedByMe: false });
|
||||
});
|
||||
|
||||
it("omits referral when the status fetch fails", async () => {
|
||||
mockReferralApi(
|
||||
() => new Response(null, { status: 500 }),
|
||||
() => new Response(JSON.stringify({ error: "boom" }), { status: 500 }),
|
||||
);
|
||||
const credits = await loadCredits();
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.eligible).toBe(true);
|
||||
expect(state.referral).toBeUndefined();
|
||||
});
|
||||
|
||||
it("claims an invite code once, notifies, and marks the account", async () => {
|
||||
mockReferralApi(() => new Response(JSON.stringify({ creditsGranted: 500 }), { status: 200 }));
|
||||
const credits = await loadCredits();
|
||||
const seen: unknown[] = [];
|
||||
credits.subscribeCreditActivations((event) => seen.push(event));
|
||||
|
||||
const result = await credits.claimReferralCode(" abc-def-ghj ");
|
||||
expect(result).toEqual({ ok: true, creditsGranted: 500 });
|
||||
expect(seen).toEqual([{ code: "referral_claimed", title: "Invite code applied", credits: 500 }]);
|
||||
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.referral?.claimedByMe).toBe(true);
|
||||
|
||||
// a second attempt is refused locally, without a network call
|
||||
const fetchCalls = fetchMock.mock.calls.length;
|
||||
const second = await credits.claimReferralCode("XYZ-XYZ-XYZ");
|
||||
expect(second.ok).toBe(false);
|
||||
expect(fetchMock.mock.calls.length).toBe(fetchCalls);
|
||||
});
|
||||
|
||||
it("surfaces backend claim errors and stays retryable", async () => {
|
||||
mockReferralApi(() => new Response(JSON.stringify({ error: "Unknown referral code" }), { status: 404 }));
|
||||
const credits = await loadCredits();
|
||||
|
||||
const bad = await credits.claimReferralCode("BAD-BAD-BAD");
|
||||
expect(bad).toEqual({ ok: false, message: "Unknown referral code" });
|
||||
|
||||
// rejected claims don't mark the account — a valid code still works
|
||||
mockReferralApi(() => new Response(JSON.stringify({ creditsGranted: 500 }), { status: 200 }));
|
||||
expect((await credits.claimReferralCode("ABC-DEF-GHJ")).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("marks the account claimed on a definitive already-claimed answer", async () => {
|
||||
mockReferralApi(() =>
|
||||
new Response(JSON.stringify({ error: "This account has already claimed a referral code" }), { status: 409 }),
|
||||
);
|
||||
const credits = await loadCredits();
|
||||
|
||||
const result = await credits.claimReferralCode("ABC-DEF-GHJ");
|
||||
expect(result.ok).toBe(false);
|
||||
const state = await credits.getCreditsState();
|
||||
expect(state.referral?.claimedByMe).toBe(true);
|
||||
});
|
||||
});
|
||||
380
apps/x/packages/core/src/billing/credits.ts
Normal file
380
apps/x/packages/core/src/billing/credits.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { getAccessToken } from '../auth/tokens.js';
|
||||
import { decodeJwtPayload } from '../auth/jwt.js';
|
||||
import { isSignedIn } from '../account/account.js';
|
||||
import { isFeatureEnabled } from '../analytics/posthog.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
import { getBillingInfo } from './billing.js';
|
||||
import { getRowboatConfig } from '../config/rowboat.js';
|
||||
import { getBillingPlanData } from '@x/shared/dist/billing.js';
|
||||
import {
|
||||
CreditActivityCodeSchema,
|
||||
type CreditActivationCatalogEntry,
|
||||
type CreditActivityCode,
|
||||
type CreditsState,
|
||||
type ReferralClaimResult,
|
||||
type ReferralState,
|
||||
} from '@x/shared/dist/credits.js';
|
||||
|
||||
// The reward catalog (names, descriptions, amounts) is owned by the backend
|
||||
// and served via GET /v1/config `creditActivations` — the app hardcodes only
|
||||
// the activity codes it knows how to trigger. Entries with codes this app
|
||||
// version doesn't recognize are dropped (nothing here can fire them).
|
||||
type KnownCatalogEntry = CreditActivationCatalogEntry & { code: CreditActivityCode };
|
||||
|
||||
async function getActivityCatalog(): Promise<KnownCatalogEntry[]> {
|
||||
const config = await getRowboatConfig();
|
||||
return (config.creditActivations ?? []).filter(
|
||||
(entry): entry is KnownCatalogEntry => CreditActivityCodeSchema.safeParse(entry.code).success,
|
||||
);
|
||||
}
|
||||
|
||||
const CLAIMED_FILE = path.join(WorkDir, 'config', 'credit_activations.json');
|
||||
|
||||
const CREDITS_FLAG_KEY = 'credit-rewards';
|
||||
const FLAG_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
let flagCache: { value: boolean; fetchedAt: number } | null = null;
|
||||
|
||||
/**
|
||||
* Whether the credit-rewards feature is on for this user. Defaults ON; the
|
||||
* PostHog `credit-rewards` flag is a remote kill switch / rollout control —
|
||||
* create it and set release conditions to turn the feature off (or ramp it)
|
||||
* for non-matching users. Evaluated against the identified rowboat user and
|
||||
* cached briefly so reward triggers don't add a network hop. ROWBOAT_CREDITS
|
||||
* env var (1/0) overrides everything for development.
|
||||
*/
|
||||
export async function isCreditsEnabled(): Promise<boolean> {
|
||||
const override = process.env.ROWBOAT_CREDITS;
|
||||
if (override === '1' || override === 'true') return true;
|
||||
if (override === '0' || override === 'false') return false;
|
||||
|
||||
if (flagCache && Date.now() - flagCache.fetchedAt < FLAG_CACHE_TTL_MS) {
|
||||
return flagCache.value;
|
||||
}
|
||||
const value = await isFeatureEnabled(CREDITS_FLAG_KEY, true);
|
||||
flagCache = { value, fetchedAt: Date.now() };
|
||||
return value;
|
||||
}
|
||||
|
||||
const PLAN_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
let planCache: { userId: string; paid: boolean; fetchedAt: number } | null = null;
|
||||
|
||||
// Rewards target free-tier users; paid (starter/pro, including trials of
|
||||
// those plans) neither see the UI nor receive grants. Cached briefly since
|
||||
// UI refreshes can arrive in bursts and /v1/me isn't free; keyed by user so
|
||||
// an account switch on the same install can't reuse the previous account's
|
||||
// answer. Throws on network failure — callers treat that as "not eligible
|
||||
// right now" and retry later.
|
||||
async function hasPaidPlan(userId: string): Promise<boolean> {
|
||||
if (planCache && planCache.userId === userId && Date.now() - planCache.fetchedAt < PLAN_CACHE_TTL_MS) {
|
||||
return planCache.paid;
|
||||
}
|
||||
const billing = await getBillingInfo();
|
||||
const category = getBillingPlanData(billing.catalog, billing.subscriptionPlanId)?.category;
|
||||
const paid = category === 'starter' || category === 'pro';
|
||||
planCache = { userId, paid, fetchedAt: Date.now() };
|
||||
return paid;
|
||||
}
|
||||
|
||||
// Claimed-state cache, keyed by Rowboat user id so switching accounts on the
|
||||
// same install doesn't hide unclaimed rewards. This is only a cache: the
|
||||
// backend enforces once-per-customer via the grants table, and a lost file
|
||||
// merely means the next occurrence of the action re-attempts activation and
|
||||
// gets a 409 (which re-marks it claimed here). Besides activity codes, the
|
||||
// store holds the pseudo-code below marking that this account has redeemed
|
||||
// someone's invite code (the backend allows one lifetime claim per account).
|
||||
const REFERRAL_CLAIMED_KEY = 'referral_claimed';
|
||||
|
||||
interface ClaimedStore {
|
||||
[userId: string]: {
|
||||
[code: string]: { claimedAt: string };
|
||||
};
|
||||
}
|
||||
|
||||
function readClaimedStore(): ClaimedStore {
|
||||
try {
|
||||
if (fs.existsSync(CLAIMED_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(CLAIMED_FILE, 'utf-8')) as ClaimedStore;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Failed to read credit_activations.json:', err);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function markClaimed(userId: string, code: CreditActivityCode | typeof REFERRAL_CLAIMED_KEY): void {
|
||||
try {
|
||||
const store = readClaimedStore();
|
||||
store[userId] = store[userId] ?? {};
|
||||
if (store[userId][code]) return;
|
||||
store[userId][code] = { claimedAt: new Date().toISOString() };
|
||||
const dir = path.dirname(CLAIMED_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CLAIMED_FILE, JSON.stringify(store, null, 2));
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Failed to persist claimed state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Supabase user id (`sub` claim) from the Rowboat access token
|
||||
* without verification — we only use it as a local cache key.
|
||||
*/
|
||||
function userIdFromToken(accessToken: string): string | null {
|
||||
const sub = decodeJwtPayload(accessToken)?.sub;
|
||||
return typeof sub === 'string' ? sub : null;
|
||||
}
|
||||
|
||||
export interface CreditActivationSuccess {
|
||||
code: CreditActivityCode | typeof REFERRAL_CLAIMED_KEY;
|
||||
title: string;
|
||||
// granted amount as confirmed by the backend
|
||||
credits: number;
|
||||
}
|
||||
|
||||
type CreditActivationListener = (event: CreditActivationSuccess) => void;
|
||||
const activationListeners = new Set<CreditActivationListener>();
|
||||
|
||||
/**
|
||||
* Be notified whenever the backend confirms a new credit grant, regardless of
|
||||
* which call site triggered it. Main subscribes once and relays the event to
|
||||
* all renderer windows (`credits:didActivate`).
|
||||
*/
|
||||
export function subscribeCreditActivations(listener: CreditActivationListener): () => void {
|
||||
activationListeners.add(listener);
|
||||
return () => activationListeners.delete(listener);
|
||||
}
|
||||
|
||||
function notifyActivation(event: CreditActivationSuccess): void {
|
||||
for (const listener of activationListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Activation listener failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a first-time-action reward with the backend, at most once per user.
|
||||
*
|
||||
* Returns the granted amount when the backend confirms a new grant, and null
|
||||
* in every other case (not signed in, already claimed, unknown code, network
|
||||
* failure). Never throws — reward activation must not break the action that
|
||||
* triggered it. Failures other than "already claimed" leave the local state
|
||||
* untouched, so the next occurrence of the action retries; the backend's
|
||||
* uniqueness guarantee makes retries safe.
|
||||
*/
|
||||
export async function maybeActivateCredit(code: CreditActivityCode): Promise<CreditActivationSuccess | null> {
|
||||
try {
|
||||
if (!(await isSignedIn())) return null;
|
||||
const accessToken = await getAccessToken();
|
||||
const userId = userIdFromToken(accessToken);
|
||||
if (!userId) return null;
|
||||
|
||||
// decisive local short-circuit first: after the one-time claim, repeat
|
||||
// actions (every email send, agent create, …) must not pay the feature
|
||||
// flag or /v1/me lookups below
|
||||
if (readClaimedStore()[userId]?.[code]) return null;
|
||||
|
||||
if (!(await isCreditsEnabled())) return null;
|
||||
if (await hasPaidPlan(userId)) return null;
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/billing/credit-activations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (response.status === 409) {
|
||||
// already granted on the backend (e.g. fresh install, second device)
|
||||
markClaimed(userId, code);
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
// 404 = code not in the backend catalog yet; anything else is transient.
|
||||
// Either way, don't mark claimed — retry on the next occurrence.
|
||||
console.warn(`[Credits] Activation of ${code} failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// validate the response shape BEFORE persisting the claim — a malformed
|
||||
// 200 must stay retryable (the retry then gets a 409 and reconciles)
|
||||
const body = await response.json() as { grant?: { amount?: number } };
|
||||
const amount = body?.grant?.amount;
|
||||
if (typeof amount !== 'number') {
|
||||
console.warn(`[Credits] Activation of ${code} returned an unexpected body`);
|
||||
return null;
|
||||
}
|
||||
markClaimed(userId, code);
|
||||
console.log(`[Credits] Activated ${code}: +${amount} credits`);
|
||||
// display name from the backend catalog; the code is a serviceable
|
||||
// fallback if the config fetch fails right now
|
||||
const title = await getActivityCatalog()
|
||||
.then((catalog) => catalog.find((entry) => entry.code === code)?.displayName)
|
||||
.catch(() => undefined);
|
||||
const success: CreditActivationSuccess = { code, title: title ?? code, credits: amount };
|
||||
notifyActivation(success);
|
||||
return success;
|
||||
} catch (err) {
|
||||
console.warn(`[Credits] Activation of ${code} errored:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const REFERRAL_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
type ReferralApiStatus = Omit<ReferralState, 'claimedByMe'>;
|
||||
|
||||
let referralCache: { userId: string; status: ReferralApiStatus; fetchedAt: number } | null = null;
|
||||
|
||||
/**
|
||||
* The user's own invite code and claim-slot usage (GET /v1/referral — the
|
||||
* backend creates the permanent code lazily on first fetch). Cached briefly
|
||||
* because state refreshes arrive in bursts; busted after a successful claim.
|
||||
* Returns null on any failure — the UI simply omits the invite row.
|
||||
*/
|
||||
async function fetchReferralStatus(accessToken: string, userId: string): Promise<ReferralApiStatus | null> {
|
||||
if (referralCache && referralCache.userId === userId && Date.now() - referralCache.fetchedAt < REFERRAL_CACHE_TTL_MS) {
|
||||
return referralCache.status;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/v1/referral`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`[Credits] Referral status fetch failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const body = await response.json() as Partial<ReferralApiStatus>;
|
||||
const { code, claimsUsed, maxClaims, referrerCredits, refereeCredits } = body ?? {};
|
||||
if (
|
||||
typeof code !== 'string' ||
|
||||
typeof claimsUsed !== 'number' ||
|
||||
typeof maxClaims !== 'number' ||
|
||||
typeof referrerCredits !== 'number' ||
|
||||
typeof refereeCredits !== 'number'
|
||||
) {
|
||||
console.warn('[Credits] Referral status has an unexpected shape');
|
||||
return null;
|
||||
}
|
||||
const status: ReferralApiStatus = { code, claimsUsed, maxClaims, referrerCredits, refereeCredits };
|
||||
referralCache = { userId, status, fetchedAt: Date.now() };
|
||||
return status;
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Referral status fetch errored:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem another user's invite code (POST /v1/referral/claims); the backend
|
||||
* grants both sides credits in one transaction and enforces all the rules
|
||||
* (one lifetime claim per account, new accounts only, per-code cap, no
|
||||
* self-claims). Failure messages are user-presentable — the backend's own
|
||||
* copy where it provided one.
|
||||
*/
|
||||
export async function claimReferralCode(rawCode: string): Promise<ReferralClaimResult> {
|
||||
const fallback: ReferralClaimResult = { ok: false, message: 'Could not apply the invite code. Please try again.' };
|
||||
try {
|
||||
const code = rawCode.trim();
|
||||
if (!code) return { ok: false, message: 'Enter an invite code.' };
|
||||
if (!(await isSignedIn())) return { ok: false, message: 'Sign in to Rowboat to use an invite code.' };
|
||||
const accessToken = await getAccessToken();
|
||||
const userId = userIdFromToken(accessToken);
|
||||
if (!userId) return { ok: false, message: 'Sign in to Rowboat to use an invite code.' };
|
||||
if (readClaimedStore()[userId]?.[REFERRAL_CLAIMED_KEY]) {
|
||||
return { ok: false, message: 'This account has already used an invite code.' };
|
||||
}
|
||||
if (!(await isCreditsEnabled())) return { ok: false, message: 'Invite codes are not available right now.' };
|
||||
|
||||
const response = await fetch(`${API_URL}/v1/referral/claims`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const body = await response.json().catch(() => null) as { creditsGranted?: number; error?: string } | null;
|
||||
|
||||
if (response.ok) {
|
||||
const amount = body?.creditsGranted;
|
||||
if (typeof amount !== 'number') {
|
||||
console.warn('[Credits] Referral claim returned an unexpected body');
|
||||
return fallback;
|
||||
}
|
||||
markClaimed(userId, REFERRAL_CLAIMED_KEY);
|
||||
// the inviter-side status (claimsUsed) is now stale for the invitee's
|
||||
// own code too — cheap to just refetch next time
|
||||
referralCache = null;
|
||||
console.log(`[Credits] Referral claim succeeded: +${amount} credits`);
|
||||
notifyActivation({ code: REFERRAL_CLAIMED_KEY, title: 'Invite code applied', credits: amount });
|
||||
return { ok: true, creditsGranted: amount };
|
||||
}
|
||||
|
||||
const message = typeof body?.error === 'string' ? body.error : undefined;
|
||||
// a definitive "already claimed" answer means the entry UI can hide for
|
||||
// good on this account (e.g. claimed earlier on another device)
|
||||
if (response.status === 409 && message?.toLowerCase().includes('already claimed')) {
|
||||
markClaimed(userId, REFERRAL_CLAIMED_KEY);
|
||||
}
|
||||
return { ok: false, message: message ?? fallback.message };
|
||||
} catch (err) {
|
||||
console.warn('[Credits] Referral claim errored:', err);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend reward catalog joined with the current user's claimed flags, for
|
||||
* the UI. Claimed flags come from the local cache (the /v1/me grants list
|
||||
* doesn't expose activation codes), so a fresh install shows an action as
|
||||
* unclaimed until it is performed again and the backend answers 409.
|
||||
*/
|
||||
export async function getCreditsState(): Promise<CreditsState> {
|
||||
const enabled = await isCreditsEnabled();
|
||||
let userId: string | null = null;
|
||||
let eligible = false;
|
||||
let catalog: KnownCatalogEntry[] = [];
|
||||
let referral: ReferralApiStatus | null = null;
|
||||
try {
|
||||
if (enabled && (await isSignedIn())) {
|
||||
const accessToken = await getAccessToken();
|
||||
userId = userIdFromToken(accessToken);
|
||||
// billing-fetch failures fall through to eligible=false — the UI
|
||||
// refreshes on the next credits/oauth event anyway
|
||||
eligible = userId != null && !(await hasPaidPlan(userId));
|
||||
if (eligible && userId) {
|
||||
catalog = await getActivityCatalog();
|
||||
referral = await fetchReferralStatus(accessToken, userId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// treat token/billing/config errors as not eligible right now
|
||||
eligible = false;
|
||||
}
|
||||
const claimed = userId ? readClaimedStore()[userId] ?? {} : {};
|
||||
return {
|
||||
enabled,
|
||||
eligible,
|
||||
// API order is preserved — the backend catalog decides display order too
|
||||
activities: catalog.map((entry) => ({
|
||||
code: entry.code,
|
||||
title: entry.displayName,
|
||||
...(entry.description !== undefined ? { description: entry.description } : {}),
|
||||
credits: entry.credits,
|
||||
claimed: !!claimed[entry.code],
|
||||
})),
|
||||
...(referral ? { referral: { ...referral, claimedByMe: !!claimed[REFERRAL_CLAIMED_KEY] } } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import path from 'path';
|
|||
import fs from 'fs/promises';
|
||||
import { CodeModeAgentStatus } from './types.js';
|
||||
import { isEngineProvisioned } from './acp/engine-provisioner.js';
|
||||
import { decodeJwtPayload } from '../auth/jwt.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -40,20 +41,6 @@ export function commonInstallPaths(binary: string): string[] {
|
|||
].map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
|
||||
const parsed = JSON.parse(json);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Given the raw credentials JSON (from a file or the macOS Keychain), decide
|
||||
// whether it represents a usable signed-in state: a valid API key, an unexpired
|
||||
// access token, or a refresh token (which can mint a new access token).
|
||||
|
|
|
|||
125
apps/x/packages/core/src/config/app_version.test.ts
Normal file
125
apps/x/packages/core/src/config/app_version.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// WorkDir is resolved at module load, so each test gets a fresh temp workdir
|
||||
// via ROWBOAT_WORKDIR + resetModules + dynamic import (same pattern as
|
||||
// filesystem/files.test.ts).
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rowboat-app-version-test-"));
|
||||
process.env.ROWBOAT_WORKDIR = tmpDir;
|
||||
vi.resetModules();
|
||||
// config.js fire-and-forgets a git init + Today.md migration on import;
|
||||
// mock them out so no repo appears (and races teardown) in the temp workdir.
|
||||
vi.doMock("../knowledge/version_history.js", () => ({
|
||||
commitAll: vi.fn(async () => undefined),
|
||||
initRepo: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../knowledge/deprecate_today_note.js", () => ({
|
||||
deprecateTodayNote: vi.fn(async () => undefined),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.ROWBOAT_WORKDIR;
|
||||
vi.doUnmock("../knowledge/version_history.js");
|
||||
vi.doUnmock("../knowledge/deprecate_today_note.js");
|
||||
vi.resetModules();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function loadAppVersion() {
|
||||
return import("./app_version.js");
|
||||
}
|
||||
|
||||
const stampPath = () => path.join(tmpDir, "config", "app-version.json");
|
||||
|
||||
async function readStamp(): Promise<unknown> {
|
||||
return JSON.parse(await fs.readFile(stampPath(), "utf-8"));
|
||||
}
|
||||
|
||||
async function writeStamp(content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(stampPath()), { recursive: true });
|
||||
await fs.writeFile(stampPath(), content);
|
||||
}
|
||||
|
||||
describe("recordAppVersion", () => {
|
||||
it("treats a missing stamp as a fresh install and writes one", async () => {
|
||||
const { recordAppVersion } = await loadAppVersion();
|
||||
|
||||
expect(recordAppVersion("1.0.0")).toBeNull();
|
||||
expect(await readStamp()).toEqual({ version: "1.0.0" });
|
||||
});
|
||||
|
||||
it("returns null when the version is unchanged", async () => {
|
||||
await writeStamp(JSON.stringify({ version: "1.0.0" }));
|
||||
const { recordAppVersion } = await loadAppVersion();
|
||||
|
||||
expect(recordAppVersion("1.0.0")).toBeNull();
|
||||
expect(await readStamp()).toEqual({ version: "1.0.0" });
|
||||
});
|
||||
|
||||
it("returns the previous version once after a change and restamps", async () => {
|
||||
await writeStamp(JSON.stringify({ version: "1.0.0" }));
|
||||
const { recordAppVersion } = await loadAppVersion();
|
||||
|
||||
expect(recordAppVersion("1.1.0")).toBe("1.0.0");
|
||||
expect(await readStamp()).toEqual({ version: "1.1.0" });
|
||||
// second call on the same version — already stamped, nothing to report
|
||||
expect(recordAppVersion("1.1.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a corrupt stamp as a fresh install (no spurious updated notice)", async () => {
|
||||
await writeStamp("{not json");
|
||||
const { recordAppVersion } = await loadAppVersion();
|
||||
|
||||
expect(recordAppVersion("1.1.0")).toBeNull();
|
||||
expect(await readStamp()).toEqual({ version: "1.1.0" });
|
||||
});
|
||||
|
||||
it("ignores a stamp whose version is not a string", async () => {
|
||||
await writeStamp(JSON.stringify({ version: 5 }));
|
||||
const { recordAppVersion } = await loadAppVersion();
|
||||
|
||||
expect(recordAppVersion("1.1.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports downgrades too — filtering is the caller's job", async () => {
|
||||
await writeStamp(JSON.stringify({ version: "2.0.0" }));
|
||||
const { recordAppVersion } = await loadAppVersion();
|
||||
|
||||
expect(recordAppVersion("1.0.0")).toBe("2.0.0");
|
||||
expect(await readStamp()).toEqual({ version: "1.0.0" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVersionUpgrade", () => {
|
||||
it.each([
|
||||
["1.0.0", "1.0.1"],
|
||||
["1.0.0", "1.1.0"],
|
||||
["1.9.0", "1.10.0"], // numeric compare, not lexicographic
|
||||
["0.9", "1.0.0"], // shorter version pads with zeros
|
||||
["1.2", "1.2.1"],
|
||||
["v1.0.0", "v1.0.1"], // leading v tolerated
|
||||
["1.2.3-beta.1", "1.2.4"], // prerelease suffix ignored
|
||||
])("upgrade: %s -> %s", async (from, to) => {
|
||||
const { isVersionUpgrade } = await loadAppVersion();
|
||||
expect(isVersionUpgrade(from, to)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["1.0.0", "1.0.0"], // unchanged
|
||||
["1.1.0", "1.0.0"], // downgrade
|
||||
["1.10.0", "1.9.9"],
|
||||
["1.2.3", "1.2.3-beta.1"], // prerelease ignored -> equal
|
||||
["1.2.3", "1.2"], // padded equal-then-lower
|
||||
["abc", "1.0.0"], // unparseable input fails quiet
|
||||
["1.0.0", "1.0.x"],
|
||||
])("not an upgrade: %s -> %s", async (from, to) => {
|
||||
const { isVersionUpgrade } = await loadAppVersion();
|
||||
expect(isVersionUpgrade(from, to)).toBe(false);
|
||||
});
|
||||
});
|
||||
53
apps/x/packages/core/src/config/app_version.ts
Normal file
53
apps/x/packages/core/src/config/app_version.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { WorkDir } from './config.js';
|
||||
|
||||
const VERSION_PATH = path.join(WorkDir, 'config', 'app-version.json');
|
||||
|
||||
/**
|
||||
* Record the running app version in WorkDir/config/app-version.json and
|
||||
* report what changed. Returns the previously recorded version when this
|
||||
* launch is the first on a new version, or null on a fresh install or when
|
||||
* the version is unchanged. A missing/corrupt stamp file is treated as a
|
||||
* fresh install so users never see a spurious "updated" notice.
|
||||
*/
|
||||
export function recordAppVersion(currentVersion: string): string | null {
|
||||
let previous: string | null = null;
|
||||
try {
|
||||
const raw = fs.readFileSync(VERSION_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
if (typeof parsed.version === 'string') previous = parsed.version;
|
||||
} catch {
|
||||
// fresh install or unreadable stamp — fall through with previous = null
|
||||
}
|
||||
|
||||
if (previous === currentVersion) return null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(VERSION_PATH), { recursive: true });
|
||||
fs.writeFileSync(VERSION_PATH, JSON.stringify({ version: currentVersion }, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[Updates] Failed to write app-version.json:', err);
|
||||
}
|
||||
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `to` is a strictly newer dotted version than `from`. Numeric
|
||||
* segment compare; a leading `v` and any prerelease suffix are ignored.
|
||||
* Unparseable input counts as not-an-upgrade, so callers stay quiet on
|
||||
* malformed stamps rather than announcing a bogus update.
|
||||
*/
|
||||
export function isVersionUpgrade(from: string, to: string): boolean {
|
||||
const parse = (v: string) => v.trim().replace(/^v/i, '').split('-')[0].split('.').map(Number);
|
||||
const a = parse(from);
|
||||
const b = parse(to);
|
||||
if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x !== y) return y > x;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
100
apps/x/packages/core/src/config/turn_limits.test.ts
Normal file
100
apps/x/packages/core/src/config/turn_limits.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// WorkDir is resolved at module load, so each test gets a fresh temp workdir
|
||||
// via ROWBOAT_WORKDIR + resetModules + dynamic import (same pattern as
|
||||
// app_version.test.ts).
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rowboat-turn-limits-test-"));
|
||||
process.env.ROWBOAT_WORKDIR = tmpDir;
|
||||
vi.resetModules();
|
||||
// config.js fire-and-forgets a git init + Today.md migration on import;
|
||||
// mock them out so no repo appears (and races teardown) in the temp workdir.
|
||||
vi.doMock("../knowledge/version_history.js", () => ({
|
||||
commitAll: vi.fn(async () => undefined),
|
||||
initRepo: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../knowledge/deprecate_today_note.js", () => ({
|
||||
deprecateTodayNote: vi.fn(async () => undefined),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.ROWBOAT_WORKDIR;
|
||||
vi.doUnmock("../knowledge/version_history.js");
|
||||
vi.doUnmock("../knowledge/deprecate_today_note.js");
|
||||
vi.resetModules();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function loadTurnLimits() {
|
||||
return import("./turn_limits.js");
|
||||
}
|
||||
|
||||
const settingsPath = () => path.join(tmpDir, "config", "turn_limits.json");
|
||||
|
||||
async function writeSettings(content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(settingsPath()), { recursive: true });
|
||||
await fs.writeFile(settingsPath(), content);
|
||||
}
|
||||
|
||||
describe("loadTurnLimitsSettings", () => {
|
||||
it("defaults to the built-in limit (50) when no file exists", async () => {
|
||||
const { loadTurnLimitsSettings } = await loadTurnLimits();
|
||||
expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
|
||||
});
|
||||
|
||||
it("reads persisted settings", async () => {
|
||||
await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 }));
|
||||
const { loadTurnLimitsSettings } = await loadTurnLimits();
|
||||
expect(await loadTurnLimitsSettings()).toEqual({
|
||||
maxModelCalls: 60,
|
||||
chatMaxModelCalls: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("fills a missing global limit from the default", async () => {
|
||||
await writeSettings(JSON.stringify({ chatMaxModelCalls: 5 }));
|
||||
const { loadTurnLimitsSettings } = await loadTurnLimits();
|
||||
expect(await loadTurnLimitsSettings()).toEqual({
|
||||
maxModelCalls: 50,
|
||||
chatMaxModelCalls: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to defaults on a corrupt file", async () => {
|
||||
await writeSettings("{not json");
|
||||
const { loadTurnLimitsSettings } = await loadTurnLimits();
|
||||
expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
|
||||
});
|
||||
|
||||
it("falls back to defaults on out-of-range values", async () => {
|
||||
await writeSettings(JSON.stringify({ maxModelCalls: 5000 }));
|
||||
const { loadTurnLimitsSettings } = await loadTurnLimits();
|
||||
expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveTurnLimitsSettings", () => {
|
||||
it("persists valid settings for the next load", async () => {
|
||||
const { saveTurnLimitsSettings, loadTurnLimitsSettings } = await loadTurnLimits();
|
||||
await saveTurnLimitsSettings({ maxModelCalls: 80, chatMaxModelCalls: 15 });
|
||||
expect(await loadTurnLimitsSettings()).toEqual({
|
||||
maxModelCalls: 80,
|
||||
chatMaxModelCalls: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects out-of-range values", async () => {
|
||||
const { saveTurnLimitsSettings } = await loadTurnLimits();
|
||||
await expect(saveTurnLimitsSettings({ maxModelCalls: 0 })).rejects.toThrow();
|
||||
await expect(saveTurnLimitsSettings({ maxModelCalls: 501 })).rejects.toThrow();
|
||||
await expect(
|
||||
saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 501 }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
42
apps/x/packages/core/src/config/turn_limits.ts
Normal file
42
apps/x/packages/core/src/config/turn_limits.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
TurnLimitsSettingsSchema,
|
||||
DEFAULT_TURN_LIMITS_SETTINGS,
|
||||
type TurnLimitsSettings,
|
||||
} from '@x/shared/dist/turn-limits.js';
|
||||
import { WorkDir } from './config.js';
|
||||
|
||||
const TURN_LIMITS_CONFIG_PATH = path.join(WorkDir, 'config', 'turn_limits.json');
|
||||
|
||||
/**
|
||||
* Load the model-call limit settings, falling back to the defaults (global
|
||||
* limit DEFAULT_MAX_MODEL_CALLS, no chat override) when the file is absent
|
||||
* or malformed.
|
||||
*/
|
||||
export async function loadTurnLimitsSettings(): Promise<TurnLimitsSettings> {
|
||||
try {
|
||||
const content = await fs.readFile(TURN_LIMITS_CONFIG_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
return TurnLimitsSettingsSchema.parse({
|
||||
...DEFAULT_TURN_LIMITS_SETTINGS,
|
||||
...(parsed && typeof parsed === 'object' ? parsed : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[TurnLimits] Error loading turn limit settings:', error);
|
||||
}
|
||||
return DEFAULT_TURN_LIMITS_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTurnLimitsSettings(
|
||||
settings: TurnLimitsSettings,
|
||||
): Promise<void> {
|
||||
const validated = TurnLimitsSettingsSchema.parse(settings);
|
||||
await fs.mkdir(path.dirname(TURN_LIMITS_CONFIG_PATH), { recursive: true });
|
||||
await fs.writeFile(
|
||||
TURN_LIMITS_CONFIG_PATH,
|
||||
JSON.stringify(validated, null, 2),
|
||||
);
|
||||
}
|
||||
165
apps/x/packages/core/src/models/codex.test.ts
Normal file
165
apps/x/packages/core/src/models/codex.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
normalizeRequestBody,
|
||||
aggregateSseResponse,
|
||||
listCodexModels,
|
||||
codexStoreMiddleware,
|
||||
CODEX_BASE_URL,
|
||||
} from './codex.js';
|
||||
import type { LanguageModelV4, LanguageModelV4CallOptions } from '@ai-sdk/provider';
|
||||
|
||||
vi.mock('../auth/chatgpt-auth.js', () => ({
|
||||
getChatGPTAccessToken: vi.fn(async () => 'test-access-token'),
|
||||
getChatGPTStatus: vi.fn(async () => ({ signedIn: true, accountId: 'acct-123' })),
|
||||
}));
|
||||
|
||||
describe('normalizeRequestBody', () => {
|
||||
it('enforces the Codex wire contract on a bare request', () => {
|
||||
const { body, forcedStream } = normalizeRequestBody(JSON.stringify({
|
||||
model: 'gpt-5.5',
|
||||
input: [],
|
||||
store: true,
|
||||
max_output_tokens: 4096,
|
||||
}));
|
||||
const parsed = JSON.parse(body);
|
||||
expect(parsed.store).toBe(false);
|
||||
expect(parsed).not.toHaveProperty('max_output_tokens');
|
||||
expect(parsed.instructions).toBe('You are a helpful assistant.');
|
||||
expect(parsed.include).toContain('reasoning.encrypted_content');
|
||||
expect(parsed.reasoning).toEqual({ summary: 'auto' });
|
||||
expect(parsed.stream).toBe(true);
|
||||
expect(forcedStream).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves caller instructions, reasoning fields, and existing includes', () => {
|
||||
const { body, forcedStream } = normalizeRequestBody(JSON.stringify({
|
||||
instructions: 'Be terse.',
|
||||
include: ['message.output_text.logprobs'],
|
||||
reasoning: { effort: 'high', summary: 'detailed' },
|
||||
stream: true,
|
||||
}));
|
||||
const parsed = JSON.parse(body);
|
||||
expect(parsed.instructions).toBe('Be terse.');
|
||||
expect(parsed.include).toEqual(
|
||||
expect.arrayContaining(['message.output_text.logprobs', 'reasoning.encrypted_content']),
|
||||
);
|
||||
expect(parsed.reasoning).toEqual({ effort: 'high', summary: 'detailed' });
|
||||
expect(forcedStream).toBe(false);
|
||||
});
|
||||
|
||||
it('passes non-JSON bodies through untouched', () => {
|
||||
expect(normalizeRequestBody('not json')).toEqual({ body: 'not json', forcedStream: false });
|
||||
});
|
||||
});
|
||||
|
||||
function sseResponse(events: string[]): Response {
|
||||
const body = events.map((e) => `data: ${e}`).join('\n\n') + '\n\n';
|
||||
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
||||
}
|
||||
|
||||
describe('aggregateSseResponse', () => {
|
||||
it('returns the response.completed payload as plain JSON', async () => {
|
||||
const res = await aggregateSseResponse(sseResponse([
|
||||
JSON.stringify({ type: 'response.created' }),
|
||||
JSON.stringify({ type: 'response.completed', response: { id: 'resp_1', output: [] } }),
|
||||
]));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/json');
|
||||
expect(await res.json()).toEqual({ id: 'resp_1', output: [] });
|
||||
});
|
||||
|
||||
it('surfaces response.failed as an error response', async () => {
|
||||
const res = await aggregateSseResponse(sseResponse([
|
||||
JSON.stringify({ type: 'response.failed', response: { error: { message: 'quota exceeded' } } }),
|
||||
]));
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json() as { error: { message: string } };
|
||||
expect(body.error.message).toBe('quota exceeded');
|
||||
});
|
||||
|
||||
it('errors when the stream ends without a terminal event', async () => {
|
||||
const res = await aggregateSseResponse(sseResponse([
|
||||
JSON.stringify({ type: 'response.created' }),
|
||||
]));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codexStoreMiddleware', () => {
|
||||
it('injects providerOptions.openai.store=false so the SDK never emits item references', async () => {
|
||||
const params = {
|
||||
prompt: [],
|
||||
providerOptions: { openai: { reasoningEffort: 'high' } },
|
||||
} as unknown as LanguageModelV4CallOptions;
|
||||
const out = await codexStoreMiddleware.transformParams!({
|
||||
type: 'stream',
|
||||
params,
|
||||
model: {} as LanguageModelV4,
|
||||
});
|
||||
expect(out.providerOptions).toEqual({
|
||||
openai: { reasoningEffort: 'high', store: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides a caller-supplied store=true', async () => {
|
||||
const params = {
|
||||
prompt: [],
|
||||
providerOptions: { openai: { store: true } },
|
||||
} as unknown as LanguageModelV4CallOptions;
|
||||
const out = await codexStoreMiddleware.transformParams!({
|
||||
type: 'generate',
|
||||
params,
|
||||
model: {} as LanguageModelV4,
|
||||
});
|
||||
expect(out.providerOptions?.openai?.store).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCodexModels', () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('discovers models with auth + originator headers, filtering hidden and sorting by priority', async () => {
|
||||
let seenHeaders: Headers | undefined;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
expect(String(input)).toContain(`${CODEX_BASE_URL}/models?client_version=`);
|
||||
seenHeaders = new Headers(init?.headers);
|
||||
return new Response(JSON.stringify({
|
||||
models: [
|
||||
{ slug: 'gpt-5.4', priority: 2, visibility: 'list' },
|
||||
{ slug: 'codex-auto-review', priority: 0, visibility: 'hide' },
|
||||
{ slug: 'gpt-5.5', priority: 1, visibility: 'list', display_name: 'GPT-5.5' },
|
||||
],
|
||||
}), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await listCodexModels();
|
||||
expect(result.providers).toHaveLength(1);
|
||||
expect(result.providers[0]?.id).toBe('codex');
|
||||
expect(result.providers[0]?.name).toBe('OpenAI Codex');
|
||||
expect(result.providers[0]?.models).toEqual([
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5', reasoning: true },
|
||||
{ id: 'gpt-5.4', reasoning: true },
|
||||
]);
|
||||
expect(seenHeaders?.get('Authorization')).toBe('Bearer test-access-token');
|
||||
expect(seenHeaders?.get('chatgpt-account-id')).toBe('acct-123');
|
||||
expect(seenHeaders?.get('originator')).toBe('codex_cli_rs');
|
||||
});
|
||||
|
||||
it('falls back to the hardcoded list when discovery fails', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await listCodexModels();
|
||||
const ids = result.providers[0]?.models.map((m) => m.id);
|
||||
expect(ids).toContain('gpt-5.6-sol');
|
||||
expect(result.providers[0]?.models.every((m) => m.reasoning === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
303
apps/x/packages/core/src/models/codex.ts
Normal file
303
apps/x/packages/core/src/models/codex.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ProviderV4, LanguageModelV4Middleware } from '@ai-sdk/provider';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { wrapLanguageModel } from 'ai';
|
||||
import { getChatGPTAccessToken, getChatGPTStatus } from '../auth/chatgpt-auth.js';
|
||||
|
||||
// "ChatGPT subscription" model provider (flavor "codex"): runs model calls
|
||||
// against the Codex backend that powers the Codex CLI, authorized by the
|
||||
// "Sign in with ChatGPT" OAuth session (auth/chatgpt-auth.ts) instead of an
|
||||
// API key. Like the "rowboat" gateway flavor, it has no models.json entry —
|
||||
// resolveProviderConfig returns a bare { flavor: "codex" } and auth is
|
||||
// injected per request here.
|
||||
//
|
||||
// The backend speaks ONLY the OpenAI Responses API, with quirks that every
|
||||
// third-party client (Codex CLI itself, Zed, others) handles the same way —
|
||||
// see codexFetch. Wire shape cross-checked against the open-source Codex CLI
|
||||
// and known-working third-party integrations on 2026-07-17.
|
||||
|
||||
export const CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex';
|
||||
|
||||
// Cloudflare in front of chatgpt.com challenges requests whose `originator`
|
||||
// isn't a known first-party Codex client, regardless of valid auth — so we
|
||||
// identify as codex_cli_rs, the same approach other subscription clients
|
||||
// ship.
|
||||
const CODEX_ORIGINATOR = 'codex_cli_rs';
|
||||
|
||||
// The backend gates the model catalog by client_version — a version that
|
||||
// predates a model family never sees it (e.g. 0.142.x gets no gpt-5.6-*).
|
||||
// When the codex CLI is installed locally, mirror the version its own cache
|
||||
// records so our catalog matches what `codex` shows in the terminal;
|
||||
// otherwise fall back to a pinned recent release.
|
||||
const CODEX_DEFAULT_CLIENT_VERSION = '0.144.5';
|
||||
let clientVersionPromise: Promise<string> | null = null;
|
||||
function codexClientVersion(): Promise<string> {
|
||||
clientVersionPromise ??= (async () => {
|
||||
try {
|
||||
const cachePath = path.join(os.homedir(), '.codex', 'models_cache.json');
|
||||
const parsed = JSON.parse(await fs.readFile(cachePath, 'utf-8')) as { client_version?: string };
|
||||
if (typeof parsed.client_version === 'string' && /^\d+\.\d+\.\d+$/.test(parsed.client_version)) {
|
||||
return parsed.client_version;
|
||||
}
|
||||
} catch { /* no local codex CLI install */ }
|
||||
return CODEX_DEFAULT_CLIENT_VERSION;
|
||||
})();
|
||||
return clientVersionPromise;
|
||||
}
|
||||
|
||||
// Used when live discovery (listCodexModels) is unreachable. Discovery wins
|
||||
// outright when it returns anything: the backend rejects retired slugs with
|
||||
// HTTP 400, so appending stale hardcoded ids to a live list only creates
|
||||
// broken picker rows.
|
||||
const CODEX_FALLBACK_MODELS = [
|
||||
'gpt-5.6-sol',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
];
|
||||
|
||||
/**
|
||||
* Wire-level normalization for the Codex backend. Applied via fetch (not
|
||||
* providerOptions) so EVERY caller — chat turns, generateText one-shots,
|
||||
* generateObject classifiers, connection tests — gets a valid request
|
||||
* without knowing codex specifics:
|
||||
*
|
||||
* - `store: false` is mandatory (the backend rejects store:true).
|
||||
* - `stream: true` is mandatory (stream:false is rejected outright);
|
||||
* non-streaming callers get their SSE aggregated back into a plain JSON
|
||||
* response below.
|
||||
* - `max_output_tokens` is rejected ("Unsupported parameter") — dropped.
|
||||
* - `instructions` must be non-empty.
|
||||
* - `include: ["reasoning.encrypted_content"]` keeps reasoning usable
|
||||
* across tool-call steps despite store:false (the AI SDK relays the
|
||||
* encrypted blobs back on subsequent requests).
|
||||
* - a reasoning summary is requested by default so the UI has visible
|
||||
* reasoning even when the user picked no explicit effort.
|
||||
*/
|
||||
export function normalizeRequestBody(raw: string): { body: string; forcedStream: boolean } {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { body: raw, forcedStream: false };
|
||||
}
|
||||
parsed = value as Record<string, unknown>;
|
||||
} catch {
|
||||
return { body: raw, forcedStream: false };
|
||||
}
|
||||
|
||||
parsed.store = false;
|
||||
delete parsed.max_output_tokens;
|
||||
if (typeof parsed.instructions !== 'string' || parsed.instructions.length === 0) {
|
||||
parsed.instructions = 'You are a helpful assistant.';
|
||||
}
|
||||
const include = new Set(Array.isArray(parsed.include) ? parsed.include as unknown[] : []);
|
||||
include.add('reasoning.encrypted_content');
|
||||
parsed.include = [...include];
|
||||
const reasoning = (parsed.reasoning !== null && typeof parsed.reasoning === 'object')
|
||||
? parsed.reasoning as Record<string, unknown>
|
||||
: {};
|
||||
reasoning.summary ??= 'auto';
|
||||
parsed.reasoning = reasoning;
|
||||
|
||||
let forcedStream = false;
|
||||
if (parsed.stream !== true) {
|
||||
parsed.stream = true;
|
||||
forcedStream = true;
|
||||
}
|
||||
return { body: JSON.stringify(parsed), forcedStream };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a forced SSE stream back into the plain JSON response the
|
||||
* non-streaming caller expects: the terminal `response.completed` event
|
||||
* carries the full response object.
|
||||
*/
|
||||
export async function aggregateSseResponse(res: Response): Promise<Response> {
|
||||
const text = await res.text();
|
||||
let completed: unknown;
|
||||
let failure: string | undefined;
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
let event: { type?: string; response?: unknown; message?: string };
|
||||
try {
|
||||
event = JSON.parse(payload) as typeof event;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'response.completed') {
|
||||
completed = event.response;
|
||||
} else if (event.type === 'response.failed') {
|
||||
const error = (event.response as { error?: { message?: string } } | undefined)?.error;
|
||||
failure = error?.message ?? 'Codex request failed';
|
||||
} else if (event.type === 'error') {
|
||||
failure = event.message ?? 'Codex request failed';
|
||||
}
|
||||
}
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set('content-type', 'application/json');
|
||||
headers.delete('content-length');
|
||||
headers.delete('content-encoding');
|
||||
if (completed !== undefined) {
|
||||
return new Response(JSON.stringify(completed), { status: 200, headers });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: failure ?? 'Codex stream ended without a completed response' } }),
|
||||
{ status: 502, headers },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite subscription-limit errors into something a user can act on. The
|
||||
* backend's 429 carries `plan_type` / `resets_at` (unix seconds) on the
|
||||
* error object; the raw message is unhelpful. Everything else passes
|
||||
* through untouched (body re-wrapped since reading consumed it).
|
||||
*/
|
||||
async function normalizeErrorResponse(res: Response): Promise<Response> {
|
||||
const raw = await res.text();
|
||||
const headers = new Headers(res.headers);
|
||||
headers.delete('content-length');
|
||||
headers.delete('content-encoding');
|
||||
let body = raw;
|
||||
if (res.status === 429) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
error?: { message?: string; plan_type?: string; resets_at?: number };
|
||||
};
|
||||
if (parsed.error) {
|
||||
const plan = parsed.error.plan_type ? ` (${parsed.error.plan_type} plan)` : '';
|
||||
let when = '';
|
||||
if (typeof parsed.error.resets_at === 'number') {
|
||||
const minutes = Math.max(1, Math.ceil((parsed.error.resets_at * 1000 - Date.now()) / 60_000));
|
||||
when = minutes >= 120 ? ` Try again in ~${Math.round(minutes / 60)}h.` : ` Try again in ~${minutes} min.`;
|
||||
}
|
||||
parsed.error.message = `You have hit your ChatGPT subscription usage limit${plan}.${when}`;
|
||||
body = JSON.stringify(parsed);
|
||||
}
|
||||
} catch { /* keep the original body */ }
|
||||
}
|
||||
return new Response(body, { status: res.status, statusText: res.statusText, headers });
|
||||
}
|
||||
|
||||
// Auth + identity + Cloudflare headers on every request; body normalization
|
||||
// on Responses calls. Throws ChatGPTAuthRequiredError when signed out —
|
||||
// callers surface "Sign in with ChatGPT".
|
||||
const codexFetch: typeof fetch = async (input, init) => {
|
||||
const token = await getChatGPTAccessToken();
|
||||
const { accountId } = await getChatGPTStatus();
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
if (accountId) headers.set('chatgpt-account-id', accountId);
|
||||
headers.set('originator', CODEX_ORIGINATOR);
|
||||
headers.set('User-Agent', `${CODEX_ORIGINATOR}/${await codexClientVersion()} (Rowboat)`);
|
||||
|
||||
let body = init?.body;
|
||||
let forcedStream = false;
|
||||
if (typeof body === 'string') {
|
||||
({ body, forcedStream } = normalizeRequestBody(body));
|
||||
}
|
||||
|
||||
const res = await fetch(input, { ...init, headers, ...(body === undefined ? {} : { body }) });
|
||||
if (!res.ok) return normalizeErrorResponse(res);
|
||||
if (forcedStream) return aggregateSseResponse(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* The SDK must BELIEVE store is false — forcing it only on the wire is not
|
||||
* enough. When the SDK thinks store is on (its default), it echoes prior
|
||||
* assistant text/reasoning parts as `item_reference` entries, and the
|
||||
* stateless Codex backend 404s them ("Items are not persisted when `store`
|
||||
* is set to false"). With providerOptions.openai.store=false its message
|
||||
* converter emits full items instead — text with content, reasoning with
|
||||
* the round-tripped encrypted_content — so every request is
|
||||
* self-contained. Injected via middleware so no caller has to know.
|
||||
*/
|
||||
export const codexStoreMiddleware: LanguageModelV4Middleware = {
|
||||
specificationVersion: 'v4',
|
||||
transformParams: async ({ params }) => ({
|
||||
...params,
|
||||
providerOptions: {
|
||||
...params.providerOptions,
|
||||
openai: {
|
||||
...params.providerOptions?.openai,
|
||||
store: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* AI SDK provider for the codex flavor. `languageModel()` on the OpenAI
|
||||
* provider resolves to the Responses API model (the only API the Codex
|
||||
* backend speaks); auth is injected per request by codexFetch, so the
|
||||
* apiKey here is a placeholder that never reaches the wire.
|
||||
*/
|
||||
export function getCodexProvider(): ProviderV4 {
|
||||
const provider = createOpenAI({
|
||||
baseURL: CODEX_BASE_URL,
|
||||
apiKey: 'chatgpt-subscription',
|
||||
fetch: codexFetch,
|
||||
});
|
||||
return {
|
||||
...provider,
|
||||
languageModel: (modelId: string) => wrapLanguageModel({
|
||||
model: provider.languageModel(modelId),
|
||||
middleware: codexStoreMiddleware,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Models available to the signed-in ChatGPT subscription, shaped like
|
||||
* listGatewayModels for the models:list merge. Live discovery against the
|
||||
* backend's own catalog; the hardcoded fallback covers offline/errors.
|
||||
* Every codex model is a reasoning model, so the flag is set directly
|
||||
* (models.dev doesn't know this flavor).
|
||||
*/
|
||||
export async function listCodexModels(): Promise<{ providers: ProviderSummary[] }> {
|
||||
let discovered: Array<{ id: string; name?: string }> = [];
|
||||
try {
|
||||
const res = await codexFetch(`${CODEX_BASE_URL}/models?client_version=${await codexClientVersion()}`);
|
||||
if (res.ok) {
|
||||
const body = await res.json() as {
|
||||
models?: Array<{ slug?: string; display_name?: string; visibility?: string; priority?: number }>;
|
||||
};
|
||||
discovered = (body.models ?? [])
|
||||
// Utility models (e.g. codex-auto-review) carry visibility
|
||||
// "hide"; picker-visible ones carry "list".
|
||||
.filter((m): m is { slug: string; display_name?: string; priority?: number } =>
|
||||
typeof m.slug === 'string' && m.slug.length > 0
|
||||
&& m.visibility !== 'hide' && m.visibility !== 'hidden')
|
||||
.sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER))
|
||||
.map((m) => ({ id: m.slug, ...(m.display_name ? { name: m.display_name } : {}) }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Codex] Model discovery failed; using fallback list:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
const models = (discovered.length > 0 ? discovered : CODEX_FALLBACK_MODELS.map((id) => ({ id })))
|
||||
.map((m) => ({ ...m, reasoning: true }));
|
||||
return {
|
||||
providers: [{
|
||||
id: 'codex',
|
||||
name: 'OpenAI Codex',
|
||||
models,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
|
@ -78,6 +78,11 @@ export async function resolveProviderConfig(name: string): Promise<z.infer<typeo
|
|||
if (name === "rowboat") {
|
||||
return { flavor: "rowboat" };
|
||||
}
|
||||
if (name === "codex") {
|
||||
// ChatGPT subscription: auth lives in chatgpt-auth.json (core auth
|
||||
// layer), never in models.json — which may not exist at all.
|
||||
return { flavor: "codex" };
|
||||
}
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const cfg = await repo.getConfig();
|
||||
const entry = cfg.providers?.[name];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|||
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
|
||||
import z from "zod";
|
||||
import { getGatewayProvider } from "./gateway.js";
|
||||
import { getCodexProvider } from "./codex.js";
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js";
|
||||
import { getChatModelIds } from "./models-dev.js";
|
||||
import { withUseCase } from "../analytics/use_case.js";
|
||||
|
|
@ -81,6 +82,8 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV4 {
|
|||
}) as unknown as ProviderV4;
|
||||
case "rowboat":
|
||||
return getGatewayProvider();
|
||||
case "codex":
|
||||
return getCodexProvider();
|
||||
default:
|
||||
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,13 @@ describe("mapReasoningEffort", () => {
|
|||
expect(mapReasoningEffort("rowboat", "x/y", "high", false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps codex like openai but forgiving on unknown support (models.dev has no codex flavor)", () => {
|
||||
expect(mapReasoningEffort("codex", "gpt-5.5", "high", undefined)).toEqual({
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
});
|
||||
expect(mapReasoningEffort("codex", "gpt-5.5", "medium", false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends nothing for flavors without a safe parameter", () => {
|
||||
expect(mapReasoningEffort("ollama", "gpt-oss", "high", true)).toBeUndefined();
|
||||
expect(mapReasoningEffort("openai-compatible", "my-vllm-model", "high", true)).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -95,6 +95,13 @@ export function mapReasoningEffort(
|
|||
if (supportsReasoning === false) return undefined;
|
||||
return { providerOptions: { openrouter: { reasoning: { effort } } } };
|
||||
}
|
||||
case "codex": {
|
||||
// Every ChatGPT-subscription model reasons, but models.dev has
|
||||
// no "codex" flavor so support reads as unknown — map unless
|
||||
// known-false rather than failing closed.
|
||||
if (supportsReasoning === false) return undefined;
|
||||
return { providerOptions: { openai: { reasoningEffort: effort } } };
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
export const skill = String.raw`
|
||||
# Charts
|
||||
|
||||
Load this skill when the user asks for a chart, graph, plot, trend, comparison, or "visualize" — or when data you've gathered (prices over time, counts per category, proportions) would land better as a picture than a table.
|
||||
|
||||
## How it works
|
||||
|
||||
Emit a fenced code block with language \`chart\` anywhere in your reply. The app renders it as an interactive chart inline (tooltips, legend, light/dark theming are handled for you — never pick colors yourself). Everything outside the fence is normal markdown.
|
||||
|
||||
\`\`\`\`
|
||||
\`\`\`chart
|
||||
{ ...JSON config... }
|
||||
\`\`\`
|
||||
\`\`\`\`
|
||||
|
||||
## Config schema
|
||||
|
||||
The ONLY fields are \`chart\`, \`data\`, \`x\`, \`y\`, \`title\` — nothing else exists (no \`label\`, \`value\`, \`series\`, \`color\`, etc.). Unknown fields are ignored; a missing \`x\` or \`y\` breaks the chart.
|
||||
|
||||
- **\`chart\`** (required): \`"line"\` | \`"bar"\` | \`"pie"\`
|
||||
- **\`data\`** (required): array of flat objects — the rows to plot. Put REAL values you gathered this turn here; never invent numbers.
|
||||
- **\`x\`** (required): key of the label/category field in each row. For pie: the slice-name key.
|
||||
- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart. For pie: the slice-value key.
|
||||
- **\`title\`** (optional): short heading shown above the chart
|
||||
|
||||
**Data must be wide-format**: one row per x value, one key per series. Never long/tidy format (a row per series-point with a series-name column) — multiple series means multiple keys in the SAME row:
|
||||
|
||||
WRONG: \`[{ "day": "Mon", "index": "S&P", "pct": 0.1 }, { "day": "Mon", "index": "Dow", "pct": 0.2 }]\`
|
||||
RIGHT: \`[{ "day": "Mon", "S&P": 0.1, "Dow": 0.2 }]\` with \`"y": ["S&P", "Dow"]\`
|
||||
|
||||
## Picking the form
|
||||
|
||||
- **line** — change over time (prices, counts by date). X is the time field.
|
||||
- **bar** — compare magnitudes across categories (issues per label, revenue per region).
|
||||
- **pie** — proportions of a whole; only with ≤ 6 slices, otherwise use a bar.
|
||||
- Two measures with very different scales (e.g. a $600 stock vs a $5 one): do NOT mix them on one chart — either normalize to % change and say so in the title, or emit two chart blocks.
|
||||
|
||||
## Examples
|
||||
|
||||
Multi-series line (comparison over time, normalized):
|
||||
|
||||
\`\`\`chart
|
||||
{
|
||||
"chart": "line",
|
||||
"title": "5-Day % Change",
|
||||
"x": "date",
|
||||
"y": ["AAPL", "NVDA", "SPY"],
|
||||
"data": [
|
||||
{ "date": "Jul 15", "AAPL": 0, "NVDA": 0, "SPY": 0 },
|
||||
{ "date": "Jul 16", "AAPL": -0.4, "NVDA": 1.2, "SPY": 0.1 },
|
||||
{ "date": "Jul 17", "AAPL": -1.1, "NVDA": 0.8, "SPY": -0.3 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Single-series bar:
|
||||
|
||||
\`\`\`chart
|
||||
{
|
||||
"chart": "bar",
|
||||
"title": "Open issues by area",
|
||||
"x": "area",
|
||||
"y": "count",
|
||||
"data": [
|
||||
{ "area": "sync", "count": 14 },
|
||||
{ "area": "editor", "count": 9 },
|
||||
{ "area": "billing", "count": 3 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Pie (x names the slice, y is its value — same keys as every other chart):
|
||||
|
||||
\`\`\`chart
|
||||
{
|
||||
"chart": "pie",
|
||||
"title": "Time spent by project",
|
||||
"x": "project",
|
||||
"y": "hours",
|
||||
"data": [
|
||||
{ "project": "Alpha", "hours": 14 },
|
||||
{ "project": "Beta", "hours": 6 },
|
||||
{ "project": "Internal", "hours": 3 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Rules
|
||||
|
||||
- Data must come from what you actually fetched or computed this turn — never fabricate points to make a chart possible. Too little real data? Say so instead of charting.
|
||||
- Keep it readable: ≤ ~30 rows per chart, ≤ 6 series. Round values sensibly.
|
||||
- Numbers must be JSON numbers, not strings ("3.1", "5%" won't plot).
|
||||
- Follow the chart with one sentence of takeaway — what the reader should see in it.
|
||||
- One chart per distinct question; don't emit several variants of the same data.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -224,7 +224,7 @@ Renders a chart from inline data.
|
|||
- \`data\` (optional): Array of objects with the data points
|
||||
- \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
|
||||
- \`x\` (required): Key name for the x-axis / label field
|
||||
- \`y\` (required): Key name for the y-axis / value field
|
||||
- \`y\` (required): Key name for the y-axis / value field — or an array of key names to plot several series on one chart (each row then carries one key per series)
|
||||
|
||||
### Table Block
|
||||
Renders a styled table from structured data.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import backgroundTaskSkill from "./background-task/skill.js";
|
|||
import notifyUserSkill from "./notify-user/skill.js";
|
||||
import appsSkill from "./apps/skill.js";
|
||||
import slackSkill from "./slack/skill.js";
|
||||
import chartsSkill from "./charts/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/runtime/assembly/skills";
|
||||
|
|
@ -77,6 +78,12 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
|
||||
content: meetingPrepSkill,
|
||||
},
|
||||
{
|
||||
id: "charts",
|
||||
title: "Charts",
|
||||
summary: "Render interactive charts (line, bar, pie) inline in the chat reply. Use when the user asks for a chart/graph/visualization or when gathered data is clearer as a picture.",
|
||||
content: chartsSkill,
|
||||
},
|
||||
{
|
||||
id: "organize-files",
|
||||
title: "Organize Files",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function parentCreated(
|
|||
config: {
|
||||
autoPermission: true,
|
||||
humanAvailable: false,
|
||||
maxModelCalls: 20,
|
||||
maxModelCalls: 50,
|
||||
},
|
||||
} as z.infer<typeof TurnEvent>,
|
||||
];
|
||||
|
|
@ -48,6 +48,7 @@ function fakeServices(opts: {
|
|||
parentEvents?: Array<z.infer<typeof TurnEvent>>;
|
||||
childResult?: Partial<HeadlessAgentResult>;
|
||||
startError?: string;
|
||||
globalMaxModelCalls?: number;
|
||||
}) {
|
||||
const started: HeadlessAgentOptions[] = [];
|
||||
const turnRuntime = {
|
||||
|
|
@ -79,7 +80,16 @@ function fakeServices(opts: {
|
|||
throw new Error("unused");
|
||||
},
|
||||
};
|
||||
return { services: { turnRuntime, headlessRunner }, started };
|
||||
return {
|
||||
services: {
|
||||
turnRuntime,
|
||||
headlessRunner,
|
||||
...(opts.globalMaxModelCalls === undefined
|
||||
? {}
|
||||
: { globalMaxModelCalls: opts.globalMaxModelCalls }),
|
||||
},
|
||||
started,
|
||||
};
|
||||
}
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
|
@ -106,7 +116,7 @@ describe("runSpawnedAgent", () => {
|
|||
model: { provider: "parent-p", model: "parent-m" },
|
||||
},
|
||||
});
|
||||
expect(started[0].maxModelCalls).toBe(20);
|
||||
expect(started[0].maxModelCalls).toBe(50);
|
||||
expect(started[0].signal).toBe(signal);
|
||||
expect(progress).toEqual([
|
||||
{ childTurnId: "child-1", agentName: "researcher", task: "find things" },
|
||||
|
|
@ -181,14 +191,35 @@ describe("runSpawnedAgent", () => {
|
|||
expect(agent.inline.instructions).toMatch(/headlessly/);
|
||||
});
|
||||
|
||||
it("rejects a model-call budget above the cap at the schema boundary", async () => {
|
||||
const { services } = fakeServices({});
|
||||
it("clamps a model-call budget above the global limit to it", async () => {
|
||||
const { services, started } = fakeServices({});
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x", max_model_calls: 50 },
|
||||
{ task: "t", instructions: "x", max_model_calls: 80 },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toMatch(/invalid input/);
|
||||
expect(result.isError).toBe(false);
|
||||
expect(started[0].maxModelCalls).toBe(50);
|
||||
});
|
||||
|
||||
it("uses the configured global limit as the sub-agent default and cap", async () => {
|
||||
const { services, started } = fakeServices({ globalMaxModelCalls: 60 });
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].maxModelCalls).toBe(60);
|
||||
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x", max_model_calls: 80 },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[1].maxModelCalls).toBe(60);
|
||||
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x", max_model_calls: 5 },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[2].maxModelCalls).toBe(5);
|
||||
});
|
||||
|
||||
it("refuses to spawn from a child parent (depth cap)", async () => {
|
||||
|
|
|
|||
|
|
@ -54,10 +54,9 @@ export const SpawnAgentInput = z.object({
|
|||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(DEFAULT_MAX_MODEL_CALLS)
|
||||
.optional()
|
||||
.describe(
|
||||
`Model-call budget for the sub-agent (default and cap: ${DEFAULT_MAX_MODEL_CALLS}).`,
|
||||
"Model-call budget for the sub-agent. Defaults to the configured global limit, which is also the cap — higher values are clamped to it.",
|
||||
),
|
||||
reasoning_effort: z
|
||||
.enum(["low", "medium", "high"])
|
||||
|
|
@ -89,6 +88,9 @@ export interface SpawnedAgentCallbacks {
|
|||
services?: {
|
||||
turnRuntime: import("../turns/api.js").ITurnRuntime;
|
||||
headlessRunner: import("./headless.js").IHeadlessAgentRunner;
|
||||
// Default and cap for the child's model-call budget; production
|
||||
// reads the user's global limit setting.
|
||||
globalMaxModelCalls?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +114,7 @@ export async function runSpawnedAgent(
|
|||
|
||||
// Lazy: this module is imported by the BuiltinTools catalog, which the
|
||||
// DI container's bridges import at startup.
|
||||
const { turnRuntime, headlessRunner } =
|
||||
const { turnRuntime, headlessRunner, globalMaxModelCalls } =
|
||||
opts.services ?? (await resolveServices());
|
||||
|
||||
let parentModel: z.infer<typeof ModelDescriptor> | undefined;
|
||||
|
|
@ -167,9 +169,12 @@ export async function runSpawnedAgent(
|
|||
},
|
||||
};
|
||||
|
||||
// The user's global limit is both the default and the cap: a parent can
|
||||
// grant a child less budget than the setting allows, never more.
|
||||
const spawnCap = globalMaxModelCalls ?? DEFAULT_MAX_MODEL_CALLS;
|
||||
const maxModelCalls = Math.min(
|
||||
input.max_model_calls ?? DEFAULT_MAX_MODEL_CALLS,
|
||||
DEFAULT_MAX_MODEL_CALLS,
|
||||
input.max_model_calls ?? spawnCap,
|
||||
spawnCap,
|
||||
);
|
||||
|
||||
let handle: Awaited<ReturnType<typeof headlessRunner.start>>;
|
||||
|
|
@ -231,6 +236,9 @@ async function resolveServices(): Promise<
|
|||
NonNullable<SpawnedAgentCallbacks["services"]>
|
||||
> {
|
||||
const { lazyResolve } = await import("../../di/lazy-resolve.js");
|
||||
const { loadTurnLimitsSettings } = await import(
|
||||
"../../config/turn_limits.js"
|
||||
);
|
||||
return {
|
||||
turnRuntime:
|
||||
await lazyResolve<import("../turns/api.js").ITurnRuntime>(
|
||||
|
|
@ -239,6 +247,7 @@ async function resolveServices(): Promise<
|
|||
headlessRunner: await lazyResolve<
|
||||
import("./headless.js").IHeadlessAgentRunner
|
||||
>("headlessAgentRunner"),
|
||||
globalMaxModelCalls: (await loadTurnLimitsSettings()).maxModelCalls,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ export const backgroundTaskTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
});
|
||||
const { maybeActivateCredit } = await import("../../../billing/credits.js");
|
||||
void maybeActivateCredit('first_bg_agent');
|
||||
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
MODEL_CALL_LIMIT_ERROR_CODE,
|
||||
|
|
@ -41,6 +41,16 @@ import type {
|
|||
ToolExecutionContext,
|
||||
} from "./tool-registry.js";
|
||||
|
||||
// createTurn defaults an omitted maxModelCalls from the fs-backed settings
|
||||
// module; mock it so tests stay hermetic (no real WorkDir reads) and can
|
||||
// steer the configured global limit.
|
||||
const turnLimitsMock = vi.hoisted(() => ({ maxModelCalls: 50 }));
|
||||
vi.mock("../../config/turn_limits.js", () => ({
|
||||
loadTurnLimitsSettings: async () => ({
|
||||
maxModelCalls: turnLimitsMock.maxModelCalls,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -3060,3 +3070,45 @@ describe("turn event bus", () => {
|
|||
expect(durable.every((e) => e.sessionId === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model-call limit resolution at createTurn (issue #768)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("model-call limit resolution", () => {
|
||||
async function createdMaxModelCalls(
|
||||
runtime: TurnRuntime,
|
||||
repo: InMemoryTurnRepo,
|
||||
config: { humanAvailable: boolean; maxModelCalls?: number },
|
||||
): Promise<number> {
|
||||
const turnId = await newTurn(runtime, { config });
|
||||
const [created] = await persisted(repo, turnId);
|
||||
if (created.type !== "turn_created") throw new Error("no turn_created");
|
||||
return created.config.maxModelCalls;
|
||||
}
|
||||
|
||||
it("an omitted limit defaults to the configured global limit", async () => {
|
||||
turnLimitsMock.maxModelCalls = 42;
|
||||
try {
|
||||
const { runtime, repo } = makeRuntime();
|
||||
expect(
|
||||
await createdMaxModelCalls(runtime, repo, { humanAvailable: true }),
|
||||
).toBe(42);
|
||||
expect(
|
||||
await createdMaxModelCalls(runtime, repo, { humanAvailable: false }),
|
||||
).toBe(42);
|
||||
} finally {
|
||||
turnLimitsMock.maxModelCalls = 50;
|
||||
}
|
||||
});
|
||||
|
||||
it("an explicit per-call limit wins over the setting", async () => {
|
||||
const { runtime, repo } = makeRuntime();
|
||||
expect(
|
||||
await createdMaxModelCalls(runtime, repo, {
|
||||
humanAvailable: true,
|
||||
maxModelCalls: 3,
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -163,7 +163,9 @@ export class TurnRuntime implements ITurnRuntime {
|
|||
config: {
|
||||
autoPermission: input.config.autoPermission ?? false,
|
||||
humanAvailable: input.config.humanAvailable,
|
||||
maxModelCalls: input.config.maxModelCalls ?? DEFAULT_MAX_MODEL_CALLS,
|
||||
maxModelCalls:
|
||||
input.config.maxModelCalls ??
|
||||
(await defaultMaxModelCalls()),
|
||||
...(input.config.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: input.config.reasoningEffort }),
|
||||
|
|
@ -1359,6 +1361,21 @@ function parseToolAdditions(
|
|||
return parsed.success ? parsed.data.toolAdditions : undefined;
|
||||
}
|
||||
|
||||
// The default budget for turns created without an explicit maxModelCalls:
|
||||
// the user's configured global limit (config/turn_limits.json). Loaded
|
||||
// lazily so this module doesn't statically pull in the fs-backed config;
|
||||
// any load problem falls back to the built-in default.
|
||||
async function defaultMaxModelCalls(): Promise<number> {
|
||||
try {
|
||||
const { loadTurnLimitsSettings } = await import(
|
||||
"../../config/turn_limits.js"
|
||||
);
|
||||
return (await loadTurnLimitsSettings()).maxModelCalls;
|
||||
} catch {
|
||||
return DEFAULT_MAX_MODEL_CALLS;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Provider API errors carry the actual upstream failure in responseBody
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Mirrors the backend's shared billing constant — credits are denominated so
|
||||
// that 100M credits == $1 of usage.
|
||||
export const CREDITS_PER_DOLLAR = 100_000_000;
|
||||
|
||||
export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']);
|
||||
export type BillingPlanCategory = z.infer<typeof BillingPlanCategorySchema>;
|
||||
|
||||
|
|
@ -29,6 +33,13 @@ export const BillingUsageBucketSchema = z.object({
|
|||
});
|
||||
export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
|
||||
|
||||
// Bonus/promotional credits granted outside the plan buckets (credit store).
|
||||
// Not sanctioned per period, so it carries a plain balance instead of a quota.
|
||||
export const BillingStoreBucketSchema = z.object({
|
||||
availableCredits: z.number(),
|
||||
});
|
||||
export type BillingStoreBucket = z.infer<typeof BillingStoreBucketSchema>;
|
||||
|
||||
export const BillingInfoSchema = z.object({
|
||||
userEmail: z.string().nullable(),
|
||||
userId: z.string().nullable(),
|
||||
|
|
@ -40,6 +51,7 @@ export const BillingInfoSchema = z.object({
|
|||
daily: BillingUsageBucketSchema.extend({
|
||||
usageDay: z.string(),
|
||||
}),
|
||||
store: BillingStoreBucketSchema,
|
||||
});
|
||||
export type BillingInfo = z.infer<typeof BillingInfoSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -47,11 +47,18 @@ export const ChartBlockSchema = z.object({
|
|||
data: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
source: z.string().optional(),
|
||||
x: z.string(),
|
||||
y: z.string(),
|
||||
// One series (string) or several (array of data keys). Pie ignores all
|
||||
// but the first.
|
||||
y: z.union([z.string(), z.array(z.string()).min(1)]),
|
||||
});
|
||||
|
||||
export type ChartBlock = z.infer<typeof ChartBlockSchema>;
|
||||
|
||||
/** The y series list regardless of which form the block used. */
|
||||
export function chartSeries(block: ChartBlock): string[] {
|
||||
return Array.isArray(block.y) ? block.y : [block.y];
|
||||
}
|
||||
|
||||
export const TableBlockSchema = z.object({
|
||||
columns: z.array(z.string()),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@ export const HttpAuthRequestSchema = z.object({
|
|||
realm: z.string().optional(),
|
||||
});
|
||||
|
||||
// A screen or window offered to the user when a page in the embedded browser
|
||||
// calls getDisplayMedia() (e.g. "Present now" in Google Meet). Main gathers
|
||||
// these via desktopCapturer and the renderer shows a picker dialog.
|
||||
export const DisplayMediaSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.enum(['screen', 'window']),
|
||||
thumbnailDataUrl: z.string(),
|
||||
appIconDataUrl: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const DisplayMediaRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
sources: z.array(DisplayMediaSourceSchema),
|
||||
});
|
||||
|
||||
export const BrowserPageElementSchema = z.object({
|
||||
index: z.number().int().positive(),
|
||||
tagName: z.string(),
|
||||
|
|
@ -144,6 +160,8 @@ export const BrowserControlResultSchema = z.object({
|
|||
export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>;
|
||||
export type BrowserState = z.infer<typeof BrowserStateSchema>;
|
||||
export type HttpAuthRequest = z.infer<typeof HttpAuthRequestSchema>;
|
||||
export type DisplayMediaSource = z.infer<typeof DisplayMediaSourceSchema>;
|
||||
export type DisplayMediaRequest = z.infer<typeof DisplayMediaRequestSchema>;
|
||||
export type BrowserPageElement = z.infer<typeof BrowserPageElementSchema>;
|
||||
export type BrowserPageSnapshot = z.infer<typeof BrowserPageSnapshotSchema>;
|
||||
export type BrowserControlAction = z.infer<typeof BrowserControlActionSchema>;
|
||||
|
|
|
|||
97
apps/x/packages/shared/src/credits.ts
Normal file
97
apps/x/packages/shared/src/credits.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { z } from 'zod';
|
||||
import { CREDITS_PER_DOLLAR } from './billing.js';
|
||||
|
||||
// First-time-action credit rewards.
|
||||
//
|
||||
// Only the activity CODES are known to the app — they anchor the trigger call
|
||||
// sites (which action fires which code) and per-code UI like icons and
|
||||
// navigation. Everything else (display name, description, credit amount) is
|
||||
// the backend catalog's to define and comes from GET /v1/config
|
||||
// `creditActivations`; the app never hardcodes amounts or copy, so the
|
||||
// catalog can change without an app release. Codes present in the config but
|
||||
// unknown to this app version are ignored (nothing here can trigger them);
|
||||
// known codes absent from the config are treated as retired and hidden.
|
||||
export const CreditActivityCodeSchema = z.enum([
|
||||
'first_gmail_connected',
|
||||
'first_email_sent',
|
||||
'first_meeting_note',
|
||||
'first_bg_agent',
|
||||
'first_app_built',
|
||||
]);
|
||||
export type CreditActivityCode = z.infer<typeof CreditActivityCodeSchema>;
|
||||
|
||||
// One catalog entry as served by GET /v1/config — `code` is an open string
|
||||
// because the backend may serve codes this app version doesn't know yet.
|
||||
export const CreditActivationCatalogEntrySchema = z.object({
|
||||
code: z.string(),
|
||||
displayName: z.string(),
|
||||
description: z.string().optional(),
|
||||
credits: z.number(),
|
||||
});
|
||||
export type CreditActivationCatalogEntry = z.infer<typeof CreditActivationCatalogEntrySchema>;
|
||||
|
||||
export const CreditActivityStateSchema = z.object({
|
||||
code: CreditActivityCodeSchema,
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
credits: z.number(),
|
||||
claimed: z.boolean(),
|
||||
});
|
||||
export type CreditActivityState = z.infer<typeof CreditActivityStateSchema>;
|
||||
|
||||
// Referral ("invite friends") state, from GET /v1/referral. The code is the
|
||||
// caller's permanent invite code; the backend grants both sides credits per
|
||||
// successful claim, up to maxClaims claims of one code. `claimedByMe` is
|
||||
// whether THIS account has redeemed someone else's code (from the local
|
||||
// claimed cache — the backend rejects repeats either way).
|
||||
export const ReferralStateSchema = z.object({
|
||||
// canonical display form, e.g. ABC-DEF-GHJ
|
||||
code: z.string(),
|
||||
claimsUsed: z.number(),
|
||||
maxClaims: z.number(),
|
||||
// what the inviter earns per claim / what the invited person earns
|
||||
referrerCredits: z.number(),
|
||||
refereeCredits: z.number(),
|
||||
claimedByMe: z.boolean(),
|
||||
});
|
||||
export type ReferralState = z.infer<typeof ReferralStateSchema>;
|
||||
|
||||
// Result of redeeming an invite code (`referral:claim`). Failure messages are
|
||||
// already user-presentable (the backend's own copy where available).
|
||||
export const ReferralClaimResultSchema = z.union([
|
||||
z.object({ ok: z.literal(true), creditsGranted: z.number() }),
|
||||
z.object({ ok: z.literal(false), message: z.string() }),
|
||||
]);
|
||||
export type ReferralClaimResult = z.infer<typeof ReferralClaimResultSchema>;
|
||||
|
||||
export const CreditsStateSchema = z.object({
|
||||
// Rewards are feature-flagged (PostHog `credit-rewards`, ROWBOAT_CREDITS
|
||||
// env override in dev); when off, no UI shows and no activation fires.
|
||||
enabled: z.boolean(),
|
||||
// signed in to Rowboat AND on the free tier — rewards target free users,
|
||||
// so BYOK-only setups and paid plans (starter/pro) are excluded. All UI
|
||||
// surfaces gate on this.
|
||||
eligible: z.boolean(),
|
||||
activities: z.array(CreditActivityStateSchema),
|
||||
// absent when not eligible or the referral status fetch failed
|
||||
referral: ReferralStateSchema.optional(),
|
||||
});
|
||||
export type CreditsState = z.infer<typeof CreditsStateSchema>;
|
||||
|
||||
// Payload of the main → renderer `credits:didActivate` event, emitted after
|
||||
// the backend confirms a grant — either a first-time-action activation or a
|
||||
// redeemed invite code (`referral_claimed`).
|
||||
export const CreditActivatedEventSchema = z.object({
|
||||
code: z.union([CreditActivityCodeSchema, z.literal('referral_claimed')]),
|
||||
title: z.string(),
|
||||
// actual granted amount, from the backend response
|
||||
credits: z.number(),
|
||||
});
|
||||
export type CreditActivatedEvent = z.infer<typeof CreditActivatedEventSchema>;
|
||||
|
||||
/** Format a raw credit amount as a dollar string, e.g. 100_000_000 → "$1". */
|
||||
export function formatCreditsAsDollars(credits: number): string {
|
||||
const dollars = credits / CREDITS_PER_DOLLAR;
|
||||
const rounded = Math.round(dollars * 100) / 100;
|
||||
return Number.isInteger(rounded) ? `$${rounded}` : `$${rounded.toFixed(2)}`;
|
||||
}
|
||||
|
|
@ -17,7 +17,9 @@ export * as frontmatter from './frontmatter.js';
|
|||
export * as bases from './bases.js';
|
||||
export * as browserControl from './browser-control.js';
|
||||
export * as billing from './billing.js';
|
||||
export * as credits from './credits.js';
|
||||
export * as notificationSettings from './notification-settings.js';
|
||||
export * as turnLimits from './turn-limits.js';
|
||||
export * as codeSessions from './code-sessions.js';
|
||||
export * as channels from './channels.js';
|
||||
export * as rowboatApp from './rowboat-app.js';
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ import type { SessionBusEvent, SessionIndexEntry, SessionState } from './session
|
|||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { AppSummarySchema, RegistryRecordSchema, RowboatAppManifestSchema } from './rowboat-app.js';
|
||||
import { BrowserStateSchema, HttpAuthRequestSchema } from './browser-control.js';
|
||||
import { BrowserStateSchema, DisplayMediaRequestSchema, HttpAuthRequestSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { CreditActivatedEventSchema, CreditsStateSchema, ReferralClaimResultSchema } from './credits.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
import { PermissionDecision, ApprovalPolicy, CodingAgent, type CodeRunFeedEvent } from './code-mode.js';
|
||||
import { NotificationSettingsSchema } from './notification-settings.js';
|
||||
import { TurnLimitsSettingsSchema } from './turn-limits.js';
|
||||
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
|
||||
import { ChannelsConfig, ChannelsStatus } from './channels.js';
|
||||
|
||||
|
|
@ -58,6 +60,22 @@ const KnowledgeSourceConfigSchema = z.object({
|
|||
filters: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// Lifecycle of the client auto-updater (apps/main/src/updater.ts).
|
||||
// - disabled: dev build — the updater never initializes
|
||||
// - unsupported: platform can't auto-update (`reason` says why)
|
||||
// - ready: an update is downloaded and installed; restart switches to it
|
||||
const UpdaterStatusSchema = z.object({
|
||||
state: z.enum(['disabled', 'unsupported', 'idle', 'checking', 'downloading', 'ready', 'error']),
|
||||
version: z.string(),
|
||||
reason: z.enum(['dev', 'platform', 'not-in-applications']).optional(),
|
||||
newVersion: z.string().optional(),
|
||||
// Markdown body of the staged update's GitHub release, when known — the
|
||||
// restart card renders it as "What's new".
|
||||
releaseNotes: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
lastCheckedAt: z.number().optional(),
|
||||
});
|
||||
|
||||
const ipcSchemas = {
|
||||
'app:getVersions': {
|
||||
req: z.null(),
|
||||
|
|
@ -767,6 +785,53 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
// --- "Sign in with ChatGPT" (subscription OAuth via the Codex CLI client) ---
|
||||
// Raw tokens are never exposed over IPC — the renderer only sees identity
|
||||
// and connection state.
|
||||
'chatgpt:getStatus': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
signedIn: z.boolean(),
|
||||
email: z.string().optional(),
|
||||
accountId: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
// Resolves when the browser flow settles (success, denial, timeout, port
|
||||
// busy, exchange failure, cancellation) — same shape as getStatus plus an
|
||||
// error string; `cancelled` marks expected teardown (no error toast).
|
||||
'chatgpt:signIn': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
signedIn: z.boolean(),
|
||||
email: z.string().optional(),
|
||||
accountId: z.string().optional(),
|
||||
cancelled: z.boolean().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
// Abort the pending sign-in attempt: stops the loopback server and settles
|
||||
// the in-flight chatgpt:signIn with a cancelled outcome. No-op when idle.
|
||||
'chatgpt:cancelSignIn': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'chatgpt:signOut': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Push event (main → renderer): ChatGPT sign-in state changed. Model
|
||||
// pickers listen and refresh — subscription models appear/disappear with
|
||||
// the session.
|
||||
'chatgpt:statusChanged': {
|
||||
req: z.object({
|
||||
signedIn: z.boolean(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
'app:openUrl': {
|
||||
req: z.object({
|
||||
url: z.string(),
|
||||
|
|
@ -798,6 +863,37 @@ const ipcSchemas = {
|
|||
url: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// Consume-once "the app was just updated" notice. `updatedFrom` is the
|
||||
// previously recorded version on the first invoke of the first launch
|
||||
// after an update, and null on every other invoke (fresh install,
|
||||
// unchanged version, or already consumed this run).
|
||||
'app:consumeUpdateInfo': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
version: z.string(),
|
||||
updatedFrom: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// --- Client auto-update (apps/main/src/updater.ts) ---
|
||||
// Pushed to all windows whenever the updater state changes.
|
||||
'updater:status': {
|
||||
req: UpdaterStatusSchema,
|
||||
res: z.null(),
|
||||
},
|
||||
'updater:getStatus': {
|
||||
req: z.null(),
|
||||
res: UpdaterStatusSchema,
|
||||
},
|
||||
// Kick off a manual check (no-op unless idle/error); progress arrives via
|
||||
// updater:status pushes. Returns the snapshot after initiating.
|
||||
'updater:check': {
|
||||
req: z.null(),
|
||||
res: UpdaterStatusSchema,
|
||||
},
|
||||
'updater:quitAndInstall': {
|
||||
req: z.null(),
|
||||
res: z.object({}),
|
||||
},
|
||||
// Tray commands issued before the renderer was ready (mirrors the pending
|
||||
// deep-link pull above): the renderer drains this once on mount.
|
||||
'app:consumePendingTrayCommand': {
|
||||
|
|
@ -2303,11 +2399,52 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.object({ ok: z.boolean() }),
|
||||
},
|
||||
// Screen-share picker for pages calling getDisplayMedia() in the embedded
|
||||
// browser (main → renderer push). The renderer shows a source picker and
|
||||
// answers via browser:displayMediaResponse.
|
||||
'browser:displayMediaRequest': {
|
||||
req: DisplayMediaRequestSchema,
|
||||
res: z.null(),
|
||||
},
|
||||
// Main → renderer: a pending display-media request was resolved without the
|
||||
// renderer answering (timed out, or the window went away), so the renderer
|
||||
// must drop the corresponding picker dialog.
|
||||
'browser:displayMediaResolved': {
|
||||
req: z.object({ requestId: z.string() }),
|
||||
res: z.null(),
|
||||
},
|
||||
// Renderer → main. Omit sourceId to cancel the request; `audio` asks for
|
||||
// system-audio loopback alongside the shared screen.
|
||||
'browser:displayMediaResponse': {
|
||||
req: z.object({
|
||||
requestId: z.string(),
|
||||
sourceId: z.string().optional(),
|
||||
audio: z.boolean().optional(),
|
||||
}),
|
||||
res: z.object({ ok: z.boolean() }),
|
||||
},
|
||||
// Billing channels
|
||||
'billing:getInfo': {
|
||||
req: z.null(),
|
||||
res: BillingInfoSchema,
|
||||
},
|
||||
// First-time-action credit rewards (see shared/src/credits.ts)
|
||||
'credits:getState': {
|
||||
req: z.null(),
|
||||
res: CreditsStateSchema,
|
||||
},
|
||||
// Main → renderer: the backend confirmed a credit grant. All activation
|
||||
// triggers live in main/core (oauth success, gmail send, meeting summarize,
|
||||
// bg-task create, app create); the renderer only listens and celebrates.
|
||||
'credits:didActivate': {
|
||||
req: CreditActivatedEventSchema,
|
||||
res: z.null(),
|
||||
},
|
||||
// Redeem another user's invite (referral) code — both sides earn credits.
|
||||
'referral:claim': {
|
||||
req: z.object({ code: z.string() }),
|
||||
res: ReferralClaimResultSchema,
|
||||
},
|
||||
// Notification settings channels
|
||||
'notifications:getSettings': {
|
||||
req: z.null(),
|
||||
|
|
@ -2319,6 +2456,17 @@ const ipcSchemas = {
|
|||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Model-call limit settings channels
|
||||
'turnLimits:getSettings': {
|
||||
req: z.null(),
|
||||
res: TurnLimitsSettingsSchema,
|
||||
},
|
||||
'turnLimits:setSettings': {
|
||||
req: TurnLimitsSettingsSchema,
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { z } from "zod";
|
|||
export const ReasoningEffort = z.enum(["low", "medium", "high"]);
|
||||
|
||||
export const LlmProvider = z.object({
|
||||
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]),
|
||||
// "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via
|
||||
// "Sign in with ChatGPT") are credential-less flavors: they never appear
|
||||
// in models.json's providers map — auth lives in their own token stores.
|
||||
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat", "codex"]),
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { z } from 'zod';
|
||||
import { BillingCatalogSchema } from './billing.js';
|
||||
import { CreditActivationCatalogEntrySchema } from './credits.js';
|
||||
|
||||
export const RowboatApiConfig = z.object({
|
||||
appUrl: z.string(),
|
||||
websocketApiUrl: z.string(),
|
||||
supabaseUrl: z.string(),
|
||||
billing: BillingCatalogSchema,
|
||||
// first-time-action reward catalog (non-archived entries); optional so the
|
||||
// app keeps working against API deployments that predate it — the rewards
|
||||
// UI just stays empty until the backend serves the catalog
|
||||
creditActivations: z.array(CreditActivationCatalogEntrySchema).optional(),
|
||||
});
|
||||
|
|
|
|||
34
apps/x/packages/shared/src/turn-limits.ts
Normal file
34
apps/x/packages/shared/src/turn-limits.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { z } from 'zod';
|
||||
import { DEFAULT_MAX_MODEL_CALLS } from './turns.js';
|
||||
|
||||
/**
|
||||
* User-configurable model-call budgets (see issue #768).
|
||||
*
|
||||
* - maxModelCalls: the global limit every turn inherits by default,
|
||||
* including headless/knowledge work and spawned sub-agents (it is also
|
||||
* the cap a parent can grant a sub-agent).
|
||||
* - chatMaxModelCalls: optional override for interactive chat turns only;
|
||||
* when absent, chat uses the global limit.
|
||||
*
|
||||
* Changing these affects only newly created turns — each turn persists its
|
||||
* resolved limit in turn_created.config.maxModelCalls.
|
||||
*/
|
||||
export const MIN_MODEL_CALL_LIMIT = 1;
|
||||
export const MAX_MODEL_CALL_LIMIT = 500;
|
||||
|
||||
const limit = z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_MODEL_CALL_LIMIT)
|
||||
.max(MAX_MODEL_CALL_LIMIT);
|
||||
|
||||
export const TurnLimitsSettingsSchema = z.object({
|
||||
maxModelCalls: limit,
|
||||
chatMaxModelCalls: limit.optional(),
|
||||
});
|
||||
|
||||
export const DEFAULT_TURN_LIMITS_SETTINGS: TurnLimitsSettings = {
|
||||
maxModelCalls: DEFAULT_MAX_MODEL_CALLS,
|
||||
};
|
||||
|
||||
export type TurnLimitsSettings = z.infer<typeof TurnLimitsSettingsSchema>;
|
||||
|
|
@ -16,7 +16,7 @@ import { ReasoningEffort } from "./models.js";
|
|||
|
||||
export type JsonValue = z.infer<ReturnType<typeof z.json>>;
|
||||
|
||||
export const DEFAULT_MAX_MODEL_CALLS = 20;
|
||||
export const DEFAULT_MAX_MODEL_CALLS = 50;
|
||||
export const MODEL_CALL_LIMIT_ERROR_CODE = "model-call-limit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
58
apps/x/pnpm-lock.yaml
generated
58
apps/x/pnpm-lock.yaml
generated
|
|
@ -4,12 +4,6 @@ settings:
|
|||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
catalogs:
|
||||
default:
|
||||
vitest:
|
||||
specifier: 4.1.7
|
||||
version: 4.1.7
|
||||
|
||||
overrides:
|
||||
vscode-jsonrpc: 8.2.0
|
||||
|
||||
|
|
@ -70,6 +64,9 @@ importers:
|
|||
chokidar:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
electron-chrome-extensions:
|
||||
specifier: ^4.9.0
|
||||
version: 4.9.0
|
||||
electron-squirrel-startup:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
|
|
@ -88,9 +85,6 @@ importers:
|
|||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
update-electron-app:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
|
|
@ -143,6 +137,9 @@ importers:
|
|||
'@x/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
electron-chrome-extensions:
|
||||
specifier: ^4.9.0
|
||||
version: 4.9.0
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: ^39.2.7
|
||||
|
|
@ -659,21 +656,25 @@ packages:
|
|||
resolution: {integrity: sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.198':
|
||||
resolution: {integrity: sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.198':
|
||||
resolution: {integrity: sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.198':
|
||||
resolution: {integrity: sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.198':
|
||||
resolution: {integrity: sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==}
|
||||
|
|
@ -2131,30 +2132,35 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
|
||||
|
|
@ -5410,6 +5416,9 @@ packages:
|
|||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
electron-chrome-extensions@4.9.0:
|
||||
resolution: {integrity: sha512-4kLlh4sPPF0ieOy/Gw7A7KrmqB07megDLqOWAWXdFLAYu+2AeOGME6uOBa80NZLbKgTqBvrsVDZ+epdszRm/EQ==}
|
||||
|
||||
electron-installer-common@0.10.4:
|
||||
resolution: {integrity: sha512-8gMNPXfAqUE5CfXg8RL0vXpLE9HAaPkgLXVoHE3BMUzogMWenf4LmwQ27BdCUrEhkjrKl+igs2IHJibclR3z3Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
|
@ -5655,10 +5664,6 @@ packages:
|
|||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
eventsource-parser@3.0.6:
|
||||
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
eventsource-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -5975,9 +5980,6 @@ packages:
|
|||
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
github-url-to-object@4.0.6:
|
||||
resolution: {integrity: sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -8757,9 +8759,6 @@ packages:
|
|||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
update-electron-app@3.1.2:
|
||||
resolution: {integrity: sha512-htLyPJv7mEoCpaSzCg0W3Hxz7ID0GC7BIhhpK32/ITG7McrWak4aOkLEOjJheKAI94AxtBVTjCk4EFIvyttw2w==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
|
|
@ -11407,7 +11406,7 @@ snapshots:
|
|||
cors: 2.8.6
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.6
|
||||
eventsource-parser: 3.1.0
|
||||
express: 5.2.1
|
||||
express-rate-limit: 7.5.1(express@5.2.1)
|
||||
jose: 6.1.3
|
||||
|
|
@ -15065,6 +15064,12 @@ snapshots:
|
|||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-chrome-extensions@4.9.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron-installer-common@0.10.4:
|
||||
dependencies:
|
||||
'@electron/asar': 3.4.1
|
||||
|
|
@ -15408,13 +15413,11 @@ snapshots:
|
|||
|
||||
events@3.3.0: {}
|
||||
|
||||
eventsource-parser@3.0.6: {}
|
||||
|
||||
eventsource-parser@3.1.0: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
dependencies:
|
||||
eventsource-parser: 3.0.6
|
||||
eventsource-parser: 3.1.0
|
||||
|
||||
execa@1.0.0:
|
||||
dependencies:
|
||||
|
|
@ -15807,10 +15810,6 @@ snapshots:
|
|||
dependencies:
|
||||
pump: 3.0.3
|
||||
|
||||
github-url-to-object@4.0.6:
|
||||
dependencies:
|
||||
is-url: 1.2.4
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -19194,11 +19193,6 @@ snapshots:
|
|||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
update-electron-app@3.1.2:
|
||||
dependencies:
|
||||
github-url-to-object: 4.0.6
|
||||
ms: 2.1.3
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue