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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/desktop/src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AskUserQuestion } from '@anton/protocol'
import { Brain, Plus, Send, Square } from 'lucide-react'
import type React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { anyProviderReady } from '../../lib/providers.js'
import type { Skill } from '../../lib/skills.js'
import type { ChatImageAttachment } from '../../lib/store.js'
import { useIsCurrentSessionWorking, useStore } from '../../lib/store.js'
Expand Down Expand Up @@ -175,6 +176,11 @@ export function ChatInput({
const handle = richInputRef.current
if (!handle) return

// Gate Enter-key / programmatic sends on provider readiness to match the
// disabled send button — avoids silently emitting turns that will fail.
const { providers, harnessStatuses } = sessionStore.getState()
if (!anyProviderReady(providers, harnessStatuses)) return

const blocks = handle.getContentBlocks()
const hasContent = blocks.some((b) => (b.type === 'text' ? b.text.trim().length > 0 : true))
if (!hasContent) return
Expand Down Expand Up @@ -261,6 +267,8 @@ export function ChatInput({
}

const hasContent = input.trim().length > 0 || imageCount > 0
const anyReady = sessionStore((s) => anyProviderReady(s.providers, s.harnessStatuses))
const canSend = hasContent && anyReady

const rootClass = `composer composer--${variant}`

Expand Down Expand Up @@ -376,10 +384,10 @@ export function ChatInput({
<button
type="button"
onClick={handleSend}
disabled={!hasContent}
className={`composer__send${!hasContent ? ' composer__send--disabled' : ''}`}
disabled={!canSend}
className={`composer__send${!canSend ? ' composer__send--disabled' : ''}`}
aria-label="Send"
data-tooltip="Send"
data-tooltip={!anyReady ? 'Connect a provider to send' : 'Send'}
>
<Send size={14} strokeWidth={1.8} />
</button>
Expand Down
18 changes: 14 additions & 4 deletions packages/desktop/src/components/chat/HarnessProviderSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { ChevronDown } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { isProviderReady } from '../../lib/providers.js'
import type { ProviderInfo } from '../../lib/store.js'
import { sessionStore } from '../../lib/store/sessionStore.js'
import { ProviderIcon } from './ModelSelector.js'
Expand All @@ -35,6 +36,7 @@ export function HarnessProviderSwitch() {
const currentProvider = sessionStore((s) => s.currentProvider)
const currentModel = sessionStore((s) => s.currentModel)
const providers = sessionStore((s) => s.providers)
const harnessStatuses = sessionStore((s) => s.harnessStatuses)
const switchSessionProvider = sessionStore((s) => s.switchSessionProvider)

const [open, setOpen] = useState(false)
Expand Down Expand Up @@ -102,6 +104,8 @@ export function HarnessProviderSwitch() {
{harnessProviders.map((p) => {
const defaultModel = p.defaultModels?.[0] ?? p.models[0] ?? currentModel ?? ''
const isCurrent = p.name === currentProvider
const status = harnessStatuses[p.name]
const ready = isProviderReady(p, harnessStatuses)
return (
<button
key={p.name}
Expand All @@ -111,7 +115,7 @@ export function HarnessProviderSwitch() {
aria-selected={isCurrent}
className="harness-provider-switch__item"
onClick={() => choose(p.name, defaultModel)}
disabled={!p.installed}
disabled={!ready}
style={{
display: 'flex',
alignItems: 'center',
Expand All @@ -121,12 +125,18 @@ export function HarnessProviderSwitch() {
background: isCurrent ? 'var(--bg-surface, #141414)' : 'transparent',
border: 'none',
borderRadius: 4,
color: p.installed ? 'var(--text, #e5e5e5)' : 'var(--text-dim, #666)',
cursor: p.installed ? 'pointer' : 'not-allowed',
color: ready ? 'var(--text, #e5e5e5)' : 'var(--text-dim, #666)',
cursor: ready ? 'pointer' : 'not-allowed',
textAlign: 'left',
fontSize: 13,
}}
title={p.installed ? undefined : `${p.name} CLI not installed on this machine`}
title={
ready
? undefined
: !status?.installed
? `${p.name} CLI not installed on this machine`
: `${p.name} CLI not logged in`
}
>
<ProviderIcon provider={p.name} size={14} />
<span style={{ flex: 1 }}>{p.name}</span>
Expand Down
32 changes: 18 additions & 14 deletions packages/desktop/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Check, ChevronDown, ChevronRight, Search, Settings } from 'lucide-react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { isProviderReady } from '../../lib/providers.js'
import type { ProviderInfo } from '../../lib/store.js'
import { sessionStore } from '../../lib/store/sessionStore.js'
import { formatModelName, providerIcons } from './model-utils.js'
Expand Down Expand Up @@ -65,14 +66,11 @@ function providerDisplayName(name: string): string {
}

type Group = 'subscription' | 'api' | 'unconfigured'
type HarnessStatusMap = ReturnType<typeof sessionStore.getState>['harnessStatuses']

function groupFor(p: ProviderInfo): Group {
if (p.type === 'harness') {
const hs = sessionStore.getState().harnessStatuses[p.name]
const ready = hs?.installed && hs?.auth?.loggedIn
return ready ? 'subscription' : 'unconfigured'
}
return p.hasApiKey ? 'api' : 'unconfigured'
function groupFor(p: ProviderInfo, harnessStatuses: HarnessStatusMap): Group {
if (!isProviderReady(p, harnessStatuses)) return 'unconfigured'
return p.type === 'harness' ? 'subscription' : 'api'
}

const SECTION_LABEL: Record<Group, string> = {
Expand Down Expand Up @@ -134,6 +132,10 @@ export function ModelPopover({
const [query, setQuery] = useState('')
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<PopoverPosition | null>(null)
// Reactive — if the user completes `codex login` while the popover is open,
// the affected provider should move from "Not configured" to "Subscriptions"
// without the user closing and reopening.
const harnessStatuses = sessionStore((s) => s.harnessStatuses)

useLayoutEffect(() => {
const update = () => {
Expand Down Expand Up @@ -173,7 +175,7 @@ export function ModelPopover({
const groups: Group[] = ['subscription', 'api', 'unconfigured']
return groups
.map((g) => {
const inGroup = providers.filter((p) => groupFor(p) === g)
const inGroup = providers.filter((p) => groupFor(p, harnessStatuses) === g)
const withModels = inGroup
.map((p) => ({
provider: p,
Expand All @@ -189,7 +191,7 @@ export function ModelPopover({
return { group: g, providers: withModels }
})
.filter((s) => s.providers.length > 0)
}, [providers, query])
}, [providers, query, harnessStatuses])

if (!pos) return null

Expand Down Expand Up @@ -287,13 +289,15 @@ export function ModelSelector() {
const currentProvider = sessionStore((s) => s.currentProvider)
const currentModel = sessionStore((s) => s.currentModel)
const providers = sessionStore((s) => s.providers)
const anyReady = sessionStore((s) =>
s.providers.some((p) => isProviderReady(p, s.harnessStatuses)),
)
const [open, setOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)

const hasAnyProvider = providers.length > 0
const hasAnyKey = providers.some((p) => p.hasApiKey || p.type === 'harness')
const displayModel = hasAnyKey ? formatModelName(currentModel) : 'Select a model'
const tag = hasAnyKey ? classifyModelTag(currentModel) : null
const displayModel = anyReady ? formatModelName(currentModel) : 'Select a model'
const tag = anyReady ? classifyModelTag(currentModel) : null

const handleSelect = (provider: string, model: string) => {
const ss = sessionStore.getState()
Expand All @@ -307,13 +311,13 @@ export function ModelSelector() {
<button
type="button"
ref={buttonRef}
className="composer__model"
className={`composer__model${anyReady ? '' : ' composer__model--empty'}`}
onClick={() => {
if (hasAnyProvider) setOpen((o) => !o)
else openSettingsModels()
}}
>
{hasAnyKey && (
{anyReady && (
<span className="composer__model-av">
<ProviderIcon provider={currentProvider} size={14} />
</span>
Expand Down
53 changes: 37 additions & 16 deletions packages/desktop/src/components/settings/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
X,
} from 'lucide-react'
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { anyProviderReady, isProviderReady } from '../../lib/providers.js'
import type { ProviderInfo } from '../../lib/store.js'
import {
ACCOUNT_COLORS,
Expand Down Expand Up @@ -768,7 +769,7 @@ function ProviderRow({
}) {
const isHarness = provider.type === 'harness'
const comingSoon = provider.name === 'claude-code'
const connected = provider.hasApiKey || provider.installed === true
const connected = sessionStore((s) => isProviderReady(provider, s.harnessStatuses))
const meta = comingSoon
? 'Subscription support coming soon'
: isHarness
Expand Down Expand Up @@ -816,6 +817,7 @@ function ModelsSection({ onOpenUsage }: { onOpenUsage?: () => void }) {
const providers = sessionStore((s) => s.providers)
const currentProvider = sessionStore((s) => s.currentProvider)
const currentModel = sessionStore((s) => s.currentModel)
const anyReady = sessionStore((s) => anyProviderReady(s.providers, s.harnessStatuses))
const sendProvidersList = sessionStore((s) => s.sendProvidersList)
const sendDetectHarnesses = sessionStore((s) => s.sendDetectHarnesses)
const sendProviderSetDefault = sessionStore((s) => s.sendProviderSetDefault)
Expand Down Expand Up @@ -860,21 +862,40 @@ function ModelsSection({ onOpenUsage }: { onOpenUsage?: () => void }) {
<>
<Group label="Default model" hint="Used when you start a new task without specifying one.">
<div className="smodel">
<div className="smodel__av">
<ProviderMark provider={currentProvider} size={20} />
</div>
<div className="smodel__body">
<div className="smodel__name">{formatModelName(currentModel) || currentModel}</div>
<div className="smodel__meta">{currentProvider}</div>
</div>
<button
ref={changeBtnRef}
type="button"
className="sm-btn sm-btn--quiet"
onClick={() => setPickerOpen((o) => !o)}
>
Change
</button>
{anyReady ? (
<>
<div className="smodel__av">
<ProviderMark provider={currentProvider} size={20} />
</div>
<div className="smodel__body">
<div className="smodel__name">{formatModelName(currentModel) || currentModel}</div>
<div className="smodel__meta">{currentProvider}</div>
</div>
<button
ref={changeBtnRef}
type="button"
className="sm-btn sm-btn--quiet"
onClick={() => setPickerOpen((o) => !o)}
>
Change
</button>
</>
) : (
<>
<div className="smodel__body">
<div className="smodel__name">No model selected</div>
<div className="smodel__meta">Connect a CLI or API key below to enable chat.</div>
</div>
<button
ref={changeBtnRef}
type="button"
className="sm-btn"
onClick={() => setPickerOpen((o) => !o)}
>
Select model
</button>
</>
)}
</div>
</Group>
<Divider />
Expand Down
10 changes: 10 additions & 0 deletions packages/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1670,6 +1670,16 @@ button {
white-space: nowrap;
}

/* Empty state ("Select a model"): no avatar is rendered, so the pill loses the
18px avatar that normally sets its height and the 3px left-padding leaves the
label jammed against the border. Match the normal pill's vertical size (avatar
18px + 3px*2 padding + 1px*2 border = 26px, box-sizing is border-box) and
balance horizontal padding so it reads as the same control. */
.composer__model--empty {
padding: 3px 10px;
min-height: 26px;
}

.composer__model:hover {
background: var(--bg-elev-3);
border-color: var(--border-strong);
Expand Down
24 changes: 24 additions & 0 deletions packages/desktop/src/lib/providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ProviderInfo } from './store.js'

type HarnessStatusMap = Record<
string,
{ installed: boolean; auth?: { loggedIn: boolean } } | undefined
>

// A harness provider is "ready" only when the CLI is installed AND logged in.
// `hasApiKey` is always true for harness providers on the backend (they don't
// need keys) so it cannot be used as a readiness signal.
export function isProviderReady(p: ProviderInfo, harnessStatuses: HarnessStatusMap): boolean {
if (p.type === 'harness') {
const s = harnessStatuses[p.name]
return !!(s?.installed && s?.auth?.loggedIn)
}
return p.hasApiKey
}

export function anyProviderReady(
providers: ProviderInfo[],
harnessStatuses: HarnessStatusMap,
): boolean {
return providers.some((p) => isProviderReady(p, harnessStatuses))
}