The backend's modelRecommendations entries are now { assistantModel,
taskModels? } (rowboatx-backend#18), mirroring models.json v2 vocabulary.
When a provider connect/sign-in seeds the assistant (and ONLY then), its
per-task recommendations become visible taskModels overrides — each
validated against the live list and skipped when equal to the chosen
assistant (only differences are written, same principle as the migration).
This restores pre-v2 economics for signed-in task models without hidden
defaults: fresh Rowboat sign-ins, new devices, and sign-out→sign-in round
trips all land with the lite-tier task models the app used to hardcode —
server-controlled, updateable without a release. Saved choices are still
never touched: no seeding happens when an assistant already exists.
- selection logic moves to @x/shared/initial-selection (core re-exports;
the renderer connect flow uses the same implementation)
- RowboatApiConfig accepts both the legacy flat shape and the nested one
(normalizeModelRecommendation), so backend deploy order and rollback
are non-events
- llm_initial_model_selected gains task_overrides_seeded
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Privacy rules encoded in one place (analytics/model-providers.ts): only
provider FLAVORS leave the app — never instance ids (future-proofing for
user-named instances), never apiKey/headers, never baseURL (local endpoints
can carry internal hostnames). Model ids allowed.
- llm_usage.provider now reports the flavor: captureLlmUsage maps the
instance id centrally (lazy, cached, falls back to the raw value — which
today equals the flavor, so history stays continuous)
- llm_provider_connected / _disconnected {flavor}: one event family across
all surfaces — BYOK at the repo choke point (new entries only; key
rotation is not a connect), rowboat at OAuth sign-in/out, codex at
ChatGPT sign-in/out
- person properties synced on every launch and after provider/assistant
changes: llm_provider_flavors (sorted, auth-derived included),
llm_provider_count, assistant_model + assistant_model_flavor
- llm_initial_model_selected {flavor, model, recommended, source}:
recommendation hit-rate for the initial-selection flow (connect /
onboarding / sign_in)
- models_config_migrated {had_assistant, materialized_overrides,
provider_count}: one-shot rollout health for the v2 migration
- ANALYTICS.md updated (llm_usage provider row, new lifecycle section,
person-properties table)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task slots (config + runtime):
- taskModels gains backgroundTask and subagent; getBackgroundTaskAgentModel
resolves its own override instead of mirroring live-notes (migration
materializes the old mirror rule so effective models are unchanged)
- spawn-agent precedence: explicit per-spawn args > configured subagent
override > parent turn's model
- repo.setProvider rejects auth-derived flavors (rowboat/codex) and merges
over existing entries so key rotation keeps contextLength/reasoningEffort
Models settings, rebuilt:
- ModelSelectionSection: ONE Assistant model picker (with an Unavailable
badge when the saved model drops off its provider's live list) + all
seven tasks defaulting to "Same as Assistant" with resolve subtext and a
clear-override link. Replaces both the signed-in and BYOK screens; the
"Auto (recommended)" sentinel and both hardcoded preferredDefaults maps
are gone — connect-time resolution uses backend recommendations
- ProvidersSection replaces the old ModelSettings card grid: status cards
(Connected · N models / error + Retry), an add-provider dialog
(choose → creds → loading → first-vs-more result states → error with
manual-model fallback), and a manage dialog (masked key + Replace,
endpoint, Refresh models, Used-by list, disconnect with consequences
spelled out). Rowboat and ChatGPT are cards in the same list
- connecting a provider seeds the assistant ONLY when none is configured —
never replaces a saved choice
- models:getConfig IPC serves selections + credential-free provider
metadata; the probe machinery (use-provider-models, liveCredentials)
is deleted — the add flow validates credentials click-driven
Onboarding:
- screen 2 is the same ProvidersSection (onboarding variant) for BOTH
paths: "Add more providers" after sign-in, "Connect a model provider"
otherwise; Continue gates on an assistant model existing. The tile grid,
per-provider key forms, and ~350 lines of duplicate state are deleted;
one step sequence replaces the two path-dependent indicators
Picker:
- split-view browse (Popover + cmdk Command): providers left with counts
and error dots, selected provider's models right, ←/→ switches provider,
↑/↓ navigates, typing collapses to a flat cross-provider list that
matches provider NAMES as well as model ids (searching "rowboat" now
works). Scoped/static pickers stay flat; public API unchanged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v1 config fossilized three schema generations: a top-level
provider/model pair (single-provider era), a providers map duplicating
credentials AND caching model lists (pre-dynamic-listing era), plus
defaultSelection and flat category overrides (hybrid era) — while several
effective models existed only as hidden curated branches in code.
v2 is the intended shape:
{ version: 2,
providers: { <instance-id>: { flavor, apiKey?, baseURL?, … } },
assistantModel: { provider, model },
taskModels: { knowledgeGraph?, meetingNotes?, liveNoteAgent?,
autoPermissionDecision?, chatTitle? },
deferBackgroundTasks? }
- one-time boot migration (core/models/migrate.ts, invoked from
ensureConfig): evaluates the v1 resolution rules — including the curated
signed-in defaults — one last time and writes the answers explicitly, so
the simplified resolvers produce identical effective models; task
overrides are written only where the old effective model differs from
inherit-from-assistant; curated ids survive solely as frozen literals in
the migration
- defaults.ts: getDefaultModelAndProvider reads assistantModel, period;
getCategoryModel/getChatTitleModel are "override else assistant"; all
SIGNED_IN_* constants and the repo's gpt-5.4 bootstrap deleted
- rowboat sign-in = connecting a provider: with no saved assistant, the
initial model is picked via selectInitialModel (backend recommendation
if the gateway lists it, else first listed) and saved; sign-out clears
rowboat-referencing selections (same dangling-ref cleanup as removing
any provider). A saved assistant is never replaced — signing in no
longer hijacks a BYOK selection
- IPC: models:saveConfig replaced by models:setProvider/removeProvider;
models:updateConfig speaks v2 keys; models:test takes {provider, model}
- renderer call sites (settings dialog, onboarding, composer) translated;
the settings dialog's delete-provider path collapses from ~100 lines of
top-level-pair juggling to removeProvider + best-effort promotion
- migration dry-run verified against a real-world signed-in config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): show chat timestamps in the user's local timezone
The per-message "Current date and time" context was built with toISOString(), giving the model a UTC time frame with no zone. It then quoted email Date: headers (sent in +0000) in UTC — an email shown as 1:06 PM IST in the inbox was described as 7:36 AM in chat.
Use local wall-clock via toLocaleString with an explicit timeZoneName and the IANA zone (matching what the legacy engine already did) so the model converts offset-bearing timestamps to local time.
* fix(x): convert email timestamps to local time for the assistant
The "now" fix gave the model a local time frame, but email dates still reached it as raw RFC 2822 Date: headers (marketing mail is sent in +0000), so it kept quoting them in UTC — a 1:06 PM IST email described as 7:36 AM in chat.
Add formatEmailDateLocal() and apply it at the three model-facing serialization points: the read-view email tool, the thread-summary line, and the gmail-sync knowledge markdown (**Date:**). The structured date field on snapshots/messages is left raw, since the renderer parses it with new Date() and formats it client-side.
* fix(x): generalize local-time formatting for model-facing timestamps
Move formatEmailDateLocal out of sync_gmail into a shared
formatTimestampForModel() util — email is just the first source of
external timestamps; Slack/calendar serialization can now reuse the
same one-line call instead of growing per-source formatters. Also add
a system-prompt instruction to always express times in the user's
local timezone, as a fallback for raw dates inside email bodies, web
content, and third-party tool output that pre-conversion can't reach.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Billing errors (out of credits, subscription required/inactive) were
rendered as null in the transcript, so if the upgrade dialog was
dismissed or missed the turn ended silently blank. Render an inline
BillingErrorNotice in both the main chat and the chat sidebar instead.
Also watch the sessions-runtime conversation (not just legacy state)
for billing failures, and track handled error ids in a Set so a
previously seen error doesn't block newer ones from popping the dialog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- RowboatApiConfig gains optional modelRecommendations (one model per
provider FLAVOR, native id format) matching the merged backend payload;
optional so older API deployments and failed fetches never break parsing
- new rowboat:getConfig IPC serves the unauthenticated /v1/config bootstrap
independent of sign-in (signed-out BYOK users need recommendations when
connecting a provider); account:getRowboat is back to pure account state
(signedIn/accessToken) — config is no longer a property of the account
- renderer useRowboatConfig() hook: one shared store for all config
consumers (appUrl dialogs/sidebar/settings), plus imperative
fetchRowboatConfig() for event-time consumers (voice, meeting) that must
await the value when starting a session
- selectInitialModel(flavor, models, recommendations): pure spec-ordered
initial pick (recommendation if listed, else first, else null) — runs
only when a provider is first connected, never over a saved choice
Inert plumbing: nothing calls selectInitialModel yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One function in core (getModelCatalog) now answers "which providers are
connected and what models does each offer", treating every provider the
same way — Rowboat gateway, ChatGPT subscription (codex), BYOK keys, and
local endpoints. Each entry carries { id, flavor, status, savedModel,
models }: id is the provider *instance* (what ModelRef.provider joins on),
flavor is the provider *type* (display naming, listing mechanics) — one
instance per flavor today, so id === flavor key, but a future multi-key
setup stays additive.
- models:list reshaped to serve the catalog plus the effective default;
req gains optional refreshProvider (Retry / Refresh models)
- main-side list cache keyed on a credential fingerprint (auto-invalidates
on key change; failures retry after 30s) — the gateway is no longer hit
on every snapshot rebuild
- useModels collapses from 4 IPC calls to 1: no more renderer-side flavor
sets, sign-in awareness, or raw models.json reads
- ModelSelector drops the live-group fetch machinery; groups render
uniformly with an inline error row + Retry. The only renderer-side fetch
left is probing typed-but-unsaved credentials (settings forms)
- channels bridge lists from the same catalog, so WhatsApp/Telegram
pickers now match the desktop picker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3.5-flash reasons by default and starved the 50-token output cap
(finishReason: length, 44/50 tokens on thinking). Map reasoning effort
to low via the existing per-provider translation, raise the cap, and
keep only the first output line.
The gateway allowlist has no google/gemini-3.5-flash-lite (only
3.1-flash-lite and full 3.5-flash), so signed-in title calls 403'd
'Model not allowed' and silently kept the placeholder.
Flash-lite under-complied with a bare word-count rule (returned
one-word titles). Few-shot examples rein it in; a code guard rejects
any remaining single-word output so the placeholder stays instead.
A tiny background LLM call names new chats in 3-5 words, replacing the
truncated-first-message placeholder once it lands. Manual renames always
win; failures silently keep the placeholder.
Model resolution (getChatTitleModel): chatTitleModel override in
models.json, else the curated gateway flash-lite when the assistant
default routes through the gateway, else the assistant's own provider -
so signed-in users who switched their default to BYOK get titles on
that provider instead of a dead gateway call.
* feat(x): sign Windows builds with Azure Trusted Signing in CI
Wire windowsSign (packager + Squirrel maker) through Azure Trusted
Signing's signtool dlib. Signing activates only when the CI env vars
are present; local and mac/linux builds are unaffected. The Windows
job stages the dlib, metadata.json, and a modern SDK signtool under
C:\azsign (space-free paths — @electron/windows-sign splits
signWithParams on spaces).
Requires repo secrets: AZURE_TENANT_ID, AZURE_CLIENT_ID,
AZURE_CLIENT_SECRET, AZURE_ENDPOINT, AZURE_CODE_SIGNING_NAME,
AZURE_CERT_PROFILE_NAME.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): use renamed Microsoft.ArtifactSigning.Client NuGet package
Microsoft renamed Trusted Signing to Artifact Signing and delisted the
old Microsoft.Trusted.Signing.Client package, which broke the setup
step. The dlib inside kept its Azure.CodeSigning.Dlib.dll filename;
locate it by search instead of a hardcoded path so future package
reshuffles fail loudly rather than at a stale path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): don't run bare signtool in the signing setup step
signtool with no arguments prints usage and exits 1, and the Actions
pwsh wrapper propagates the last native exit code as the step result,
failing the job after an otherwise successful setup. Log the signtool
version from file metadata instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): stage only the current platform's node-pty prebuilds
Every build shipped all platforms' prebuilt binaries. Windows code
signing walks every .node file in the app and signtool hard-fails on
the Mach-O darwin pty.node ("file format cannot be signed"). Filtering
to the host platform fixes signing and drops dead weight from all
installers. The Linux CI-compiled prebuild and the node-gyp self-heal
path both still stage correctly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Onboarding's LLM step predates ChatGPT-subscription support, so the option
was reachable in the composer and Settings but missing from first-run
setup. Mirrors Settings exactly: when the OpenAI provider card is selected,
the ChatGPT subscription sign-in block renders directly under the OpenAI
API-key field (gated on llmProvider === "openai", same as
settings-dialog.tsx), reusing the existing useChatGPT hook / chatgpt:* IPC
— a nested block, NOT a standalone provider card. ChatGPT never enters the
LlmProviderFlavor union, so the BYOK config/save machinery is untouched.
Signing in counts as a connected provider (chatgpt.status.signedIn) so
Continue unlocks with no API key, and the block shows the same three-state
UI (Sign In / Waiting / Connected + Sign Out) as Settings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Invert the tab scroll logic in the settings dialog content area:
instead of listing every tab that should scroll, default to
overflow-y-auto and only opt out the JSON editor tabs (which render
a self-scrolling full-height textarea) and note-tagging (which
manages its own scroll region). New tabs are no longer clipped by
the fixed-height dialog unless explicitly opted out.
* feat(x): ModelSelector liveCredentials — scoped live group from unsaved creds
A provider being configured right now has no store group (models.json
not saved yet) and openrouter/aigateway/ollama/openai-compatible have
no static catalog either. liveCredentials synthesizes the scoped live
group from the form's typed credentials, winning over a saved group
whose stored key may be stale. Same 'some credential present' bar as
the store; useProviderModels' debounce + cache prevent fetch spray.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): explicit Assistant model picker on the BYOK provider card
The four category fields said 'Same as assistant' while the assistant
model itself was invisible (silently auto-resolved at connect). Adds an
Assistant model ModelSelector above them: scoped to the card's flavor,
live-fetching with the CURRENT typed credentials (liveCredentials, so
unsaved keys work), allowCustom for arbitrary ids. The Auto sentinel
keeps today's silent resolve and shows what it would pick right now
('Auto (currently gpt-5.4)') once the live list settles. An explicit
pick writes through setPrimaryModel into models[0] (models[1..]
preserved) and connect uses it verbatim — no silent swap; Auto follows
exactly the old resolve-then-save flow including the on-demand fetch.
Replaces the openai-compatible-only free-text Model field (customModel
state + unconfirmed-model sync effect deleted): typing the id in the
picker's search covers the no-/models servers, for every provider.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(x): make meeting system-audio capture work on Linux (#722)
Meeting transcription failed on Linux because the system-audio side of
the recorder was unreliable there. Two real problems, neither of which
was the one proposed in the issue:
- desktopCapturer.getSources() goes through the Wayland screencast
portal, which can block on a system dialog or hang outright. Audio-only
display-media requests (meeting transcription discards the video track)
now answer with the requesting frame as the mandatory video source and
Chromium's PulseAudio loopback for audio - no portal involved.
- Chromium captures loopback audio at the default sink's monitor-source
volume. Desktop tools sometimes leave that volume near zero, which
turns the capture into digital silence with no error anywhere. Before
capture starts, ensureLinuxMonitorVolume() raises it back to 100%
(raise-only, best-effort via pactl).
The issue's proposed fix - enumerating PipeWire/Pulse monitor sources
via enumerateDevices() and capturing with getUserMedia - is not viable:
Chromium filters monitor sources out of input-device enumeration on
Linux (audio_manager_pulse.cc), so they never appear. Verified against
Electron 39 / Chromium 142 that audio:'loopback' works on Linux through
the Pulse layer (PipeWire's pipewire-pulse or classic PulseAudio).
On Linux the loopback track mirrors the output device rather than the
meeting app, so "ended" never fires when the meeting closes; auto-stop
relies on the existing silence detector. Capture failures now surface a
toast pointing at PipeWire/PulseAudio instead of silently going idle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): restore Linux system-audio capture lost in main merge
The merge of main (5ad7ddb1) resolved the conflict in main.ts by taking
main's refactored configureAppDisplayMediaHandler wholesale, dropping
ensureLinuxMonitorVolume() and the Linux branch of the display-media
handler while keeping the now-unused execFile/promisify imports.
Re-apply both into the new handler structure: audio-requesting display
media on Linux is answered with the requesting frame + 'loopback' (no
Wayland portal), after raising a near-zero default-sink monitor volume
back to 100% via pactl (raise-only, best-effort).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Signing out with the dialog open left the Models tab on the stale
signed-in section: the dialog snapshotted oauth:getState once per open
into local state and nothing updated it. Main DOES broadcast sign-out —
disconnectProvider emits { provider, success: false } on the
oauth:didConnect channel — and the useModels store already refetches on
that channel, so the fix is to derive the dialog's rowboatConnected
from useModels().isRowboatConnected (single source of truth). The
section now swaps live on sign-out AND sign-in; the composer's rowboat
group already followed the store. Documents the channel's
fires-on-disconnect-too behavior at the subscription site and covers
the sign-out flip in the store tests.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(x): add useModels hook — shared model-catalog store
One module-level store behind useSyncExternalStore: N mounted consumers
share one snapshot and one in-flight fetch (models:list + models.json +
oauth state + llm:getDefaultModel). Re-fetches on models-config-changed,
oauth:didConnect and chatgpt:statusChanged; newest-wins seq guard ports
the composer's epoch race protection. Extracted verbatim from the chat
composer's loadModelConfig for the ModelSelector consolidation (Phase 1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): add ModelSelector extracted from the chat composer
Controlled picker ({provider, model} value + onChange, persistence stays
with the caller) rendering the composer's exact UI: provider-grouped
catalog + live groups via useProviderModels, search filter, standalone
default row, locked-model display, and the reasoning-effort control
(shown only for catalog-flagged reasoning models, '' reported up when
the model loses reasoning support). Catalog comes from useModels().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): migrate chat composer to useModels + ModelSelector
Deletes the composer's inline copies of the catalog loader (oauth state,
llm:getDefaultModel, models:list, models.json parsing, event listeners)
and the picker/effort dropdown JSX in favor of the shared hook and
component. Behavior preserved: picks persist globally via
models:updateConfig({defaultSelection}) + models-config-changed dispatch,
locked-model display, per-turn unpersisted reasoning effort, and the
tab-activation re-fetch. SelectedModel/ReasoningEffortLevel exports stay
for existing consumers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): ModelSelector settings modes — sentinel, field trigger, provider scoping, custom ids
defaultOption pins a top entry that selects null ('Rowboat default' /
'Same as assistant'); variant='field' renders a SelectTrigger-style
full-width box; providerFilter scopes to one provider's group, falling
back to the raw models:list catalog (newly exposed on the useModels
snapshot as catalogByProvider) for providers mid-setup; allowCustom
offers a Use-"<text>" row when the search matches nothing. onChange
widens to ModelRef | null — the composer guards the (unreachable
without defaultOption) null and is otherwise untouched. Still zero
persistence inside the component.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): signed-in Settings Models section on ModelSelector
The four hybrid dropdowns (Assistant / Knowledge graph / Background
agents / Permission checks) become field-variant ModelSelectors with a
'Rowboat default' sentinel, sourcing the shared store's groups — BYOK
providers without a static catalog (e.g. OpenRouter) now live-fetch
their full list inside the open dropdown instead of showing only the
saved model (user-reported bug). State is ModelRef | null; the
provider::model hybridKey round-trip, options collector, and local
providerDisplayNames map are gone. Persistence unchanged: nothing
writes until Save's single models:updateConfig + models-config-changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): BYOK per-provider category fields on ModelSelector
Knowledge graph / Meeting notes / Track block / Auto-permission become
provider-scoped field-variant ModelSelectors (providerFilter=active
card, allowCustom, 'Same as assistant' sentinel), replacing the static
catalog Select + free-text Input pair. Values still persist as bare
model-id strings inside providers[flavor] through the existing save
path — the ModelRef ↔ string adapter lives at the call site. Deletes
the section's own models:list loader (modelsCatalog/modelsLoading/
showModelInput/LlmModelOption); scoped pickers fall back to the store's
catalogByProvider for providers mid-setup, and configured providers now
live-fetch full lists (OpenRouter bug fix on the BYOK side too).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(x): assert defaultModel refreshes on models-config-changed
Covers the settings-Save → event → store refetch leg that updates a
fresh composer tab's trigger label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): ModelSelector inheritance mode + un-scoped custom ids
inheritDefault?: {label} is defaultOption with placeholder styling —
one sentinel code path, the trigger renders the label muted when
nothing overrides. allowCustom no longer requires providerFilter: an
un-scoped custom entry splits 'provider/model' on the first slash
(OpenRouter-style ids must be typed provider-qualified) and pairs
slash-less text with the global default's provider, matching how the
runtime resolves a provider-less override. Adds modelOverrideToRef /
refToModelOverride adapters for the two-optional-strings persistence
shape shared by BackgroundTask and LiveNote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): bg-task model/provider override on ModelSelector
The Advanced section's two free-text Inputs become one inheritance-mode
picker ('(global default)' sentinel, allowCustom for arbitrary ids).
Same draft state and bg-task:patch save path; a task with model set but
no provider round-trips untouched via the shared override adapters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): live-note model/provider override on ModelSelector
Same inheritance-mode picker as bg-tasks (the section was a copy-paste
of it), same shared adapters, unchanged live-note:set save path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): EnableAgentsDialog model picker on ModelSelector
Replaces the flat models:list-flattening <select> with an
inheritance-mode picker pre-seeded from the pinned agent's bg-task
model/provider. '(default)' (null) still enables with an active-only
patch so agents keep their pinned model; a picked model still patches
each bundled agent once. Drops the dialog's private catalog fetch —
the shared store already has the data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): ModelSelector staticOptions mode for caller-supplied lists
When staticOptions is set the picker renders ONLY those rows (+ the
defaultOption sentinel) — no catalog groups, no live fetches. Entries
are opaque engine ids carried in ref.model with provider '', so
call-site adapters stay one-liners ('default' ↔ null, id ↔ string).
Rows whose label differs from their id show the id as secondary text,
disambiguating Claude's colliding 'Opus' labels (alias + concrete id).
Adds toSelectorOptions() in code-agent-options to split an engine list
into sentinel label + rows, and a triggerTitle prop for header tooltips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): new-session dialog on ModelSelector
Deletes loadModelOptions() — the admitted composer-mirror duplicate —
in favor of the shared store: the Rowboat-mode picker (still rendered
only for rowboat mode, gated on configured providers) is a field
ModelSelector whose null sentinel keeps 'default → omit model/provider'
create-payload semantics. The agent-model select becomes a
staticOptions picker fed by toSelectorOptions(fetchCodeAgentOptions),
preserving per-agent reload/reset and the 'default → omit agentModel'
payload rule. The effort select stays a plain Select on purpose — a
caller-supplied axis, not model selection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): code session header agent-model picker on ModelSelector
The header's model DropdownMenu becomes a pill ModelSelector with
staticOptions (search + Opus id disambiguation for free); the effort
dropdown stays untouched. codeSession:update payload semantics
unchanged — the sentinel still sends the literal 'default'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(x): delete dead onboarding-modal.tsx
Nothing imports it — App.tsx renders OnboardingModal from
components/onboarding/index.tsx (the steps-based flow). Its rich
four-category model picker was superseded by the silent auto-resolve
in use-onboarding-state; the only other mention was a stale comment in
useConnectors, updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
parseChartSource extracted as a pure function with tests for the three
render states (streaming / invalid / parsed), the label/value alias
mapping, and multi-series y arrays. doc-collab's chart block reference
now mentions the array form of y.
Live testing showed models emitting pie configs with label/value
instead of x/y, and long-format line data with a series column —
schema-invalid fences sat on 'Preparing chart…' forever. The renderer
now maps the label/value aliases before validating, distinguishes
complete-but-invalid JSON (explicit 'config invalid' state) from
still-streaming JSON (placeholder), and the skill spells out the exact
field list, pie's x/y meaning, and wide-vs-long data format with a
wrong/right example.
The chat's markdown fence override now renders ```chart blocks
(the same ChartBlockSchema notes use) as interactive recharts inline —
the mermaid pattern. A new ChartRenderer handles line/bar/pie with
multi-series support, light/dark theming, and a quiet placeholder
while the fence body is still streaming.
ChartBlockSchema.y widens to string | string[] for multi-series
charts (notes chart block updated to map series too), with a
chartSeries() helper in shared.
A new 'charts' skill teaches the model the fence format, form
selection (line/bar/pie, no mixed scales), and data honesty rules;
loaded on demand like any skill.
Address review: remove ITurnLimitsResolver, its bridge and container
registration. createTurn now defaults an omitted maxModelCalls straight
from the turn-limits config (lazy import, built-in fallback), with no
chat/headless distinction in the runtime — the chat UI passes its
optional chat override explicitly via the existing sessions:sendMessage
maxModelCalls field. turn_limits.ts now uses fs/promises throughout.
Per review: no special handling in the agent loop when the model-call
limit is hit — the turn fails as before. Instead the default limit
rises from 20 to 50 (global, and chat inherits it) so the limit rarely
triggers in practice; users can tune it in Settings > Advanced. The
friendly limit-failure message in the chat error bubble stays, since it
points users at the setting.
The budget notice now tells the model to answer the user's request with
the content it already gathered before noting what's unfinished, and
the fallback limit-failure bubble drops the 'work is saved' phrasing.
Interactive turns no longer die with a raw 'Model call limit of N
reached' error. The final budgeted model call is composed as a wrap-up:
the durable request records wrapUp: true, and the composer strips tools
and appends a notice telling the model to answer with what it has,
state what's unfinished, and mention the limit is configurable in
Settings -> Advanced. The turn then completes normally with that answer.
Headless turns (and a wrap-up call that still emits tool calls) keep
the hard model-call-limit failure so automation retains the
machine-readable outcome; sub-agents already surface partial results.
If a hard limit failure still reaches the chat UI, the error bubble now
explains the limit and points at the setting instead of the raw error.
A chat override equal to the global limit is behaviorally identical to
no override, so persist it as absent and collapse it on load. Stepping
the empty chat field (which lands on the global value) no longer pins a
redundant override that makes the field open with a number forever.
Validation range is now 1-500. The Advanced tab's native number inputs
are replaced with a segmented minus/value/plus stepper (custom buttons,
digits-only typing, range clamping, immediate save on step). The chat
override gets a smaller placeholder, steps from the effective global
limit when empty, and has a clear button to fall back to the global
limit.
Replace the hardcoded 20-call turn budget with user settings: a global
model-call limit that every turn inherits (headless/knowledge work,
scheduled agents, sub-agents) plus an optional chat-only override for
interactive turns. Settings live in config/turn_limits.json (1-100,
default 20) and are edited from a new Advanced tab in Settings.
The limit is resolved at turn creation via an ITurnLimitsResolver
injected into TurnRuntime, keyed on humanAvailable; explicit per-call
overrides still win, and the resolved value is persisted in
turn_created.config.maxModelCalls as before, so historical and
in-flight turns are unaffected by settings changes. Sub-agents now
default to and are capped by the global limit instead of the constant.
The notes pane was a bare max-h clip: nothing signalled that more content
sat below the fold, and Squirrel.Windows never supplies release notes at
all, so Windows users only ever saw the static fallback line. Squirrel.Mac
is no better in the edit-after-publish window — it snapshots the release
body at download time, which is how v0.7.7 shipped a card showing two
paragraphs of a ten-item release.
- Render the notes in a bordered, inset scroll box (max-h + overflow-y)
so the frame and scrollbar make the overflow visible on both platforms.
- Backfill the notes from the GitHub release body after update-downloaded:
gives Windows notes for the first time and replaces macOS's stale
snapshot with the current body. On fetch failure the snapshot (or the
fallback line) stands.
- Strip the release body's leading "What's new" heading, which duplicated
the card's own label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reward only fired in the apps:create IPC handler, which no UI path
invokes — the copilot builds apps by writing the folder directly, so the
credit never triggered. Fire it from apps:list instead, off observed
state: any valid non-installed (kind=local) app counts as built.
Catalog installs (.rowboat-install.json present) don't count.
* feat(x): first-time-action credit rewards
Reward signed-in free-tier users the first time they connect Gmail, send
an email, take meeting notes, set up a background agent, or build an app.
- core credits service: POST /v1/billing/credit-activations, once per
(user, code); local claimed cache in config/credit_activations.json
keyed by user; 409 reconciles claimed state; other failures retry on
the next occurrence
- triggers at the action success sites in main/core (google oauth
success, gmail:sendReply, meeting:summarize, bg-task create sites,
apps:create); grants broadcast to windows via credits:didActivate
- eligibility: signed-in free tier only (paid starter/pro excluded);
whole feature behind the PostHog credit-rewards flag (default ON,
flag acts as kill switch; ROWBOAT_CREDITS env override for dev)
- UI: sidebar 'Earn $X in credits' pill with checklist popover
(dismissible, rows navigate to each action), settings Account section
with earned state + bonus balance, confetti celebration on grant
- shared: credits catalog/types, credits:getState IPC, BillingInfo
gains store bucket; decodeJwtPayload moved to auth/jwt.ts for reuse
Backend catalog codes deployed separately (rowboatx-backend).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): fetch reward catalog from the API, hardcode only activity codes
Review feedback: names, descriptions, and credit amounts are backend-owned
and subject to change; the app now reads them from GET /v1/config
'creditActivations' instead of a hardcoded catalog. Only the activity codes
remain in the app — they anchor the trigger call sites and per-code UI
(icons, navigation).
- shared: drop CREDIT_ACTIVITIES; add CreditActivationCatalogEntrySchema;
RowboatApiConfig gains optional creditActivations
- core: getCreditsState joins the fetched catalog (unknown codes dropped,
API order preserved) with local claimed flags; celebration title comes
from the catalog with the code as fallback
- renderer: description now optional; settings section hides while the
API serves no catalog
Requires the API to serve creditActivations: [{code, displayName,
description?, credits}] on /v1/config; until then the rewards UI stays
hidden and activations still work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): invite-a-friend referral rewards
Surfaces the backend's referral codes (GET /v1/referral, POST
/v1/referral/claims) in the app. The inviter's permanent code appears as
an "Invite friends" item in the sidebar rewards popover and the settings
rewards list, with copy button and claims progress ($5 per friend who
joins, up to 3). The invited side redeems via a self-gating "Have an
invite code?" entry in onboarding's completion step and in settings;
a successful claim fires the existing celebration and refreshes the
balance. Backend error copy (unknown code, own code, already claimed,
cap reached, account too old) is shown inline verbatim.
The one-lifetime-claim state is cached per user in the local claimed
store (pseudo-code referral_claimed), reconciled when the backend
answers "already claimed" from another install. Referral status is
cached for 60s and busted after a claim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds the model-call half of "Use your ChatGPT subscription": a new
credential-less "codex" provider flavor (like "rowboat") that runs calls
against the Codex backend using the OAuth session from chatgpt-auth.
- core/models/codex.ts: AI SDK OpenAI provider in Responses mode. A
wrapLanguageModel middleware injects providerOptions.openai.store=false
on every call so the SDK emits full self-contained items (text +
reasoning with encrypted_content) instead of item_reference entries the
stateless backend 404s. A wire-level fetch wrapper adds auth
(bearer + chatgpt-account-id) and Cloudflare headers (originator), and
backstops the contract: store:false, stream forced with SSE aggregation
for non-streaming callers, max_output_tokens dropped, encrypted-
reasoning include, friendly usage-limit errors
- live model discovery via /codex/models, gated by client_version
(mirrored from the local codex CLI's models_cache.json so the catalog
matches what `codex` shows); visibility "hide" utility models filtered;
display names carried through; hardcoded fallback list for offline
- createProvider/resolveProviderConfig/mapReasoningEffort codex cases
- models:list merges the "OpenAI Codex" catalog while signed in with
ChatGPT; chatgpt:statusChanged push event on sign-in/out
- composer picker shows the group gated on the session and refreshes on
status changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lets users connect their ChatGPT Plus/Pro subscription instead of pasting
an API key. Sign-in half only: the codex model client consumes the session
via getChatGPTAccessToken().
- PKCE OAuth against auth.openai.com using the official Codex CLI public
client id (constants verified against the openai/codex sources); system
browser + loopback callback server on the pre-registered fixed port
127.0.0.1:1455, state validated, stale callbacks rejected
- token store (core): single config/chatgpt-auth.json holding non-secret
display fields (email, accountId, expiresAt) plus the token material
encrypted via the safeStorage cipher bridge (plaintext-with-marker
fallback when no keychain, matching the GitHub token path); no token
value ever written in the clear or logged
- getChatGPTAccessToken(): single-flight refresh within 5 min of expiry;
refresh rejection (400/401) clears to a clean signed-out state and
throws typed ChatGPTAuthRequiredError; 5xx/network kept as transient
- sign-out with best-effort token revocation
- IPC chatgpt:getStatus/signIn/cancelSignIn/signOut — raw tokens never
cross to the renderer; real cancellation settles the in-flight attempt,
frees the port, and a retry starts a fresh attempt (new PKCE/state)
- settings UI: ChatGPT Subscription section on the OpenAI card
(sign in / waiting + cancel / connected as {email} + sign out) via a
useChatGPT hook
Vitest covers the token store, single-flight refresh, and
transient-vs-terminal refresh handling.
The assistant's read-view email search stored its query in
emailInitialSearchQuery and never cleared it, so every later visit to the
email section remounted EmailView with the old query re-injected into the
search box — even after the user had cleared it. Clear the stored query on
any email navigation that doesn't carry an explicit search (sidebar,
openEmailView) so deep-linked searches apply exactly once.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Apps section improvements:
- Landing with no apps of your own now falls through to the Catalog tab
with a banner pointing at installing from the catalog or building your
own (links into the copilot app-builder flow).
- Catalog renders as a card grid matching the My Apps visual language
(shared card-theme helpers) instead of one-line rows, with star
button, INSTALLED badge, and Install/Open actions on each card.
- Apps can be pinned to the nav sidebar: right-click an app card to
add/remove; pinned rows sit under the Apps nav item, click opens the
app, right-click removes. Persisted in localStorage (x:pinned-apps).
- My Apps shows a hint that right-click pins an app to the sidebar.
- Uninstall/delete unpins the app, and the Apps view prunes pins for
apps that no longer exist (only off an authoritative server list).
GitHub stars rate-limit backoff (packages/core/src/apps/stars.ts):
once GitHub reports the budget spent (403/429 + x-ratelimit-remaining:
0), gate all star fetches until the advertised reset, serving stale
cached counts instead of burning requests on guaranteed 403s.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(x): client auto-update with non-interrupting UX
Auto-update via update.electronjs.org (Squirrel), replacing the native
update dialog with a state machine pushed to the renderer:
- Non-modal "restart to update" toast, deferred while a call/turn is
active and retracted if one starts; 24h "Later" snooze persisted in
main so it survives window reloads
- Offline detection: network errors get a soft `offline` state instead
of a red failure, and don't emit update_failed analytics
- Update-waiting badges: macOS dock badge, Windows taskbar overlay icon
- macOS move-to-Applications prompt parented to the main window, with a
failure fallback dialog and focus re-check after a manual drag
- Settings > Help: version, manual check with accurate transient
"You're up to date" feedback, per-state messaging
- "Updated to vX" card on first launch after an upgrade (downgrades
restamp silently); "What's new" links to release notes
- Toaster: follow app dark mode (fixes unreadable description text) and
restyle to theme tokens
- Window state persistence so restart-to-update feels lossless
- Tests for version stamping and upgrade comparison
* feat(x): replace update toast with Zed-style titlebar chip
The restart-to-update prompt moves from a sonner toast to a persistent,
non-interrupting titlebar indicator: a spinner while an update downloads,
then a 'Restart to update' chip once staged. Clicking restarts into the
new version; the x snoozes the chip for 24h via the existing persisted
snooze. Busy-deferral is dropped - the chip never interrupts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): bottom-left update card with inline release notes
Reframe the restart prompt per PR feedback: by the time the user sees it,
Squirrel has already installed the update — the prompt only asks for a
restart (Chrome-style), and being loud about what shipped matters more
than being unobtrusive during early adoption.
- Replace the titlebar chip with a bottom-left "Update available" card
showing the new version, an inline "What's new" section rendered from
the GitHub release notes, and Later / Release notes / Restart now
- Release notes come from Squirrel.Mac's update feed (update.electronjs.org
passes the release body through); Squirrel.Windows only reports the
release name, so missing notes are backfilled from the GitHub API
- Drop the 24h snooze machinery — Later/× just dismiss for the session
- Drop offline detection (soft `offline` state) — separate PR later
- Drop the macOS move-to-Applications prompt/move button — separate PR
later; Settings still explains why updates are unavailable outside
/Applications
- Drop window state persistence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): aggregate release notes across skipped versions, persistent up-to-date status
- Updater aggregates release bodies for every version between the running
app and the update target, with a commit-log fallback when no release in
range has notes
- CI fills empty release bodies with GitHub's auto-generated notes
- Settings shows a persistent 'You're up to date' line with last-checked
time instead of a transient confirmation
* fix(x): restore updater:quitAndInstall lines dropped in merge
The conflict resolution in 67d6a542 truncated the updater:quitAndInstall
entry in both the shared IPC schema and the main-process handler, leaving
an unclosed brace that broke the shared package build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): simplify release notes handling per review feedback
Release notes are a process concern, not the updater's: drop the
CI job that auto-filled empty release bodies and the in-app GitHub
API backfill (backfillReleaseNotes + commitLogFallback). The update
card now adapts instead — it renders the notes Squirrel supplies,
or a static "Bug fixes and improvements." line when empty.
Also document that updateElectronApp() configures the same
autoUpdater singleton our listeners observe (no race), and that
gen-install-loading.sh is a manual one-off tool whose committed
GIF feeds Squirrel's loadingGif.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): drop update-electron-app, drive autoUpdater directly
With notifyUser: false the package reduced to setFeedURL + an
immediate checkForUpdates() + a 10-minute unconditional timer, plus
guards we already have (isPackaged, platform, app-ready). Inline
those three lines instead and remove the dependency.
The interval now runs through the existing guarded checkForUpdates()
(no-op unless idle/error), so ticks no longer emit Squirrel.Mac
"check already in progress" errors or make Squirrel.Windows
re-download an already-staged update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Three capability upgrades to the embedded browser pane:
- Screen share: getDisplayMedia() in the browser partition now shows a
Chrome-style source picker (screens/windows with thumbnails, optional
system audio for screens) instead of silently capturing the primary
screen. Requests are denied when the pane is hidden. The app's own
session keeps its auto-loopback handler for meeting transcription.
- Notifications: the browser partition grants the notifications
permission so sites (WhatsApp Web, Gmail, ...) can post native OS
notifications while loaded. Background Web Push remains unavailable.
- Extensions: electron-chrome-extensions wired to the browser session.
Unpacked extensions load from ~/.rowboat/extensions/<name>/; tab
lifecycle is mirrored into chrome.tabs, and a <browser-action-list>
toolbar hosts action icons/popups next to the address bar. NOTE: the
library is GPL-3.0/Patron dual-licensed — licensing decision required
before shipping in a release build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>