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
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- **[issue-3180] Agent terminals on Windows get a working environment** — the environment filter for agent shell sessions was written for macOS/Linux and dropped variables Windows needs, including `Path` itself (Windows spells it mixed-case, which the filter didn't match), plus `SystemRoot`, `ComSpec`, `TEMP`, and the per-user `APPDATA` folders where the coding CLIs keep their configuration and credentials. Windows sessions now keep those while still withholding API keys and tokens.
## Image generation

- **Test Connection in Image Gen settings now tests the CLI whose tab you're on.** Pressing it from the Grok CLI or Agy CLI tab probed whichever backend was saved as the default instead — so an Agy test answered `codex — codex-cli 0.145.0`, which looked like Agy had somehow resolved to the Codex binary. The button now probes the provider for the open tab (Backend, Tokens, Expose, and Test still probe the saved default backend), and every result is shown only under the tab that asked for it — so a slow probe you left behind can't surface under the provider you switched to.
- **[issue-3186] Agy CLI is now an opt-in image-generation backend.** Users can enable Antigravity in Image Gen settings, select an installed or custom model, and send text-to-image work through its `generate_image` tool with queue progress, cancellation, gallery metadata, cleaner options, and CoS tool registration. Each render runs in an isolated scratch directory and only a signature-verified directed image is imported; image-edit surfaces continue to use an edit-capable backend.
## Game

Expand Down
4 changes: 4 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,7 @@ is not reliably announced.
### Migrate the bespoke gallery-upload callers onto `GalleryImagePicker allowUpload`

- [ ] `client/src/components/imageGen/GalleryImagePicker.jsx` gained an `allowUpload` prop (#2953) that encapsulates the file → `readFileAsBase64` → `uploadGalleryImage` → normalize → select flow inside the shared picker. Three existing callers still roll their own upload path with a separate file `<input>` + `toast` alongside the picker: `client/src/components/music/AlbumsManager.jsx:177`, `client/src/components/music/ArtistsManager.jsx:147`, and `client/src/pages/Authors.jsx:167`. Migrate each to `<GalleryImagePicker allowUpload>` and delete the bespoke upload handlers so the duplication the prop was designed to end actually collapses. Deferred from #2953 (adjacent files outside that issue's scope). Surfaced by the /simplify altitude review.

### Let the Image Gen "Test Render" panel pick its provider

- [ ] `client/src/components/settings/ImageGenTab.jsx` — Test Connection now probes the provider whose sub-tab is open, but the Test tab's `handleRenderTest` still renders with `saved.mode` (the saved default backend). So a user who wants to smoke-test an Agy or Grok render must first make it their default, which is the same friction the Test Connection fix just removed. Add a provider `<select>` to the Test Render panel (default: the saved backend, options limited to enabled backends via `isModeUsable`/`pickUsableMode`'s client mirror), thread the chosen mode into `generateImage({ mode })`, and update the panel copy from "the active backend" to name the selected one. Out of scope for the Test Connection fix (needs new UI, not a re-target of an existing call). Surfaced by the /simplify altitude review.
49 changes: 34 additions & 15 deletions client/src/components/settings/ImageGenTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,22 @@ const clampParallel = (n, bounds = PARALLEL_FALLBACK) =>
// grouping works whether ImageGenTab is hosted in a plain Drawer (ImageGen /
// VideoGen / StoryboardPanel) or elsewhere — no tabs-within-tabs. The active
// tab deep-links via the `mediaTab` search param (useDrawerTab).
const MEDIA_TABS = [
// `probeMode` binds a provider tab to the backend the shared Test Connection
// button should probe. Without it the button always probed the *saved default*
// mode, so testing from the Grok or Agy tab reported the Codex CLI's version
// ("codex — codex-cli 0.145.0") — the answer to a question nobody asked.
// Tabs with no provider of their own (Backend / Tokens / Expose / Test) leave
// it undefined and fall back to the saved default backend.
// Exported so a test can assert every IMAGE_GEN_MODE is tagged onto some tab —
// a new backend that forgets its `probeMode` would otherwise silently regress
// to probing the saved default again.
export const MEDIA_TABS = [
{ id: 'backend', label: 'Backend', icon: ImageIcon },
{ id: 'external', label: 'External', icon: Cloud },
{ id: 'local', label: 'Local', icon: Cpu },
{ id: 'codex', label: 'Codex CLI', icon: Terminal },
{ id: 'grok', label: 'Grok CLI', icon: Sparkles },
{ id: 'agy', label: 'Agy CLI', icon: Terminal },
{ id: 'external', label: 'External', icon: Cloud, probeMode: IMAGE_GEN_MODE.EXTERNAL },
{ id: 'local', label: 'Local', icon: Cpu, probeMode: IMAGE_GEN_MODE.LOCAL },
{ id: 'codex', label: 'Codex CLI', icon: Terminal, probeMode: IMAGE_GEN_MODE.CODEX },
{ id: 'grok', label: 'Grok CLI', icon: Sparkles, probeMode: IMAGE_GEN_MODE.GROK },
{ id: 'agy', label: 'Agy CLI', icon: Terminal, probeMode: IMAGE_GEN_MODE.AGY },
{ id: 'tokens', label: 'Tokens', icon: Key },
{ id: 'expose', label: 'Expose', icon: Globe },
{ id: 'test', label: 'Test', icon: Sparkles },
Expand Down Expand Up @@ -297,13 +306,20 @@ export function ImageGenTab() {
if (mediaTab === 'agy' && agyEnabled) refreshAgyModels();
}, [mediaTab, agyEnabled, refreshAgyModels]);

// Probe the provider whose tab is open (undefined → the saved default
// backend). Every result is tagged with the tab that asked for it and only
// rendered under that tab, so neither a tab switch nor a probe still in
// flight can show one provider's answer under another provider's panel.
const activeTab = MEDIA_TABS.find((t) => t.id === mediaTab);
const probeMode = activeTab?.probeMode;

const checkStatus = useCallback(() => {
setChecking(true);
getImageGenStatus()
.then(setStatus)
.catch(() => setStatus({ connected: false, reason: 'Check failed' }))
getImageGenStatus(probeMode)
.then((result) => setStatus({ ...result, forTab: mediaTab }))
.catch(() => setStatus({ connected: false, reason: 'Check failed', forTab: mediaTab }))
.finally(() => setChecking(false));
}, []);
}, [probeMode, mediaTab]);

const isDirty = mode !== saved.mode
|| normalizeUrl(sdapiUrl) !== saved.sdapiUrl
Expand Down Expand Up @@ -1104,9 +1120,10 @@ export function ImageGenTab() {
</div>

{/* Persistent global save + status bar — applies across every tab since
Save persists the whole imageGen config and Test Connection probes the
active backend. Kept outside the tab panel so it's reachable no matter
which section is open. */}
Save persists the whole imageGen config. Test Connection probes the
provider whose tab is open (falling back to the saved default backend
on the non-provider tabs). Kept outside the tab panel so it's
reachable no matter which section is open. */}
<div className="flex items-center gap-3 pt-3 border-t border-port-border">
<button
type="button"
Expand All @@ -1122,12 +1139,14 @@ export function ImageGenTab() {
onClick={checkStatus}
disabled={checking || isDirty}
className="flex items-center gap-2 px-4 py-2 bg-port-border hover:bg-port-border/70 text-white text-sm rounded-lg transition-colors disabled:opacity-50 min-h-[40px]"
title={isDirty ? 'Save settings first to test' : 'Probe the active backend'}
title={isDirty
? 'Save settings first to test'
: `Probe ${probeMode ? activeTab.label : 'the active backend'}`}
>
{checking ? <BrailleSpinner /> : <Zap size={14} />}
Test Connection
</button>
{status && (
{status?.forTab === mediaTab && (
<span className={`text-sm ${status.connected ? 'text-port-success' : 'text-port-error'}`}>
{status.connected
? `${status.mode} — ${status.model || status.pythonPath}`
Expand Down
64 changes: 62 additions & 2 deletions client/src/components/settings/ImageGenTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ vi.mock('../../hooks/useMediaJobSse', () => ({
}));

import {
getSettings, getToolsList, updateSettings, listAgyImageModels,
getSettings, getToolsList, updateSettings, listAgyImageModels, getImageGenStatus,
} from '../../services/api';
import { useHfTokenStatus } from '../../hooks/useHfTokenStatus';
import { ImageGenTab } from './ImageGenTab';
import { ImageGenTab, MEDIA_TABS } from './ImageGenTab';
import { IMAGE_GEN_MODE } from '../../lib/imageGenBackends';

const renderTab = async (initialEntries = ['/media/image']) => {
render(
Expand Down Expand Up @@ -135,6 +136,65 @@ describe('ImageGenTab grouped tabs', () => {
expect(screen.getByRole('button', { name: /^Save$/ })).toBeTruthy();
});

it('tags every image-gen backend onto a tab, so a new backend cannot silently probe the default instead', () => {
const probed = MEDIA_TABS.map((t) => t.probeMode).filter(Boolean);
expect([...probed].sort()).toEqual([...Object.values(IMAGE_GEN_MODE)].sort());
});

it('probes the provider whose tab is open, not the saved default backend', async () => {
getImageGenStatus.mockResolvedValue({ connected: true, mode: 'agy', model: 'agy 1.2.3' });
await renderTab();

// On a provider tab the probe targets THAT provider — testing from the Agy
// tab must not report the saved default (external/codex) backend.
fireEvent.click(screen.getByRole('tab', { name: /Agy CLI/i }));
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
await waitFor(() => expect(getImageGenStatus).toHaveBeenCalledWith('agy'));

getImageGenStatus.mockResolvedValue({ connected: true, mode: 'grok', model: 'grok-cli 0.0.30' });
fireEvent.click(screen.getByRole('tab', { name: /Grok CLI/i }));
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
await waitFor(() => expect(getImageGenStatus).toHaveBeenLastCalledWith('grok'));
});

it('falls back to the saved default backend on tabs with no provider of their own', async () => {
getImageGenStatus.mockResolvedValue({ connected: true, mode: 'external', model: 'flux-v1' });
await renderTab();
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
await waitFor(() => expect(screen.getByText(/external — flux-v1/)).toBeTruthy());
expect(getImageGenStatus).toHaveBeenCalledWith(undefined);
});

it('hides a probe result once the user switches to another tab', async () => {
getImageGenStatus.mockResolvedValue({ connected: true, mode: 'grok', model: 'grok-cli 0.0.30' });
await renderTab();
fireEvent.click(screen.getByRole('tab', { name: /Grok CLI/i }));
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
await waitFor(() => expect(screen.getByText(/grok — grok-cli 0\.0\.30/)).toBeTruthy());

fireEvent.click(screen.getByRole('tab', { name: /Agy CLI/i }));
expect(screen.queryByText(/grok — grok-cli 0\.0\.30/)).toBeNull();
});

it('never shows an in-flight probe result under the tab the user switched to', async () => {
// Probe started on Grok, answered only after the user moved to Agy — the
// late response must not surface as if it described Agy.
let resolveProbe;
getImageGenStatus.mockReturnValue(new Promise((resolve) => { resolveProbe = resolve; }));
await renderTab();
fireEvent.click(screen.getByRole('tab', { name: /Grok CLI/i }));
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));

fireEvent.click(screen.getByRole('tab', { name: /Agy CLI/i }));
resolveProbe({ connected: true, mode: 'grok', model: 'grok-cli 0.0.30' });
await waitFor(() => expect(screen.getByRole('button', { name: /Test Connection/i }).disabled).toBe(false));
expect(screen.queryByText(/grok — grok-cli 0\.0\.30/)).toBeNull();

// Going back to Grok shows the answer that was actually asked for there.
fireEvent.click(screen.getByRole('tab', { name: /Grok CLI/i }));
expect(screen.getByText(/grok — grok-cli 0\.0\.30/)).toBeTruthy();
});

it('preserves the save behavior — dirtying a field enables Save and PUTs the full imageGen patch', async () => {
await renderTab();
fireEvent.click(screen.getByRole('tab', { name: /External/i }));
Expand Down