Skip to content
Open
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
40 changes: 40 additions & 0 deletions docs/frontend-ui-audit-2026-08-04/CodexSetup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Frontend UI Audit — CodexSetup

**File:** `src/scaffold/WizardSystem/variants/KeyVault/components/setup/CodexSetup.tsx` (269 LOC)
**Date:** 2026-08-04
**Auditor:** Codex session

## D1 — Raw HTML vs Design System

| Line | Element | Verdict | Reason | Suggested change |
| ---- | ----------- | ------- | ---------------------------------------------------------------------------------------------- | ---------------- |
| — | No findings | — | The changed interaction uses the existing `Button` and `InlineAlert` design-system components. | — |

## D2 — Arbitrary Tailwind Value vs Token

| Line | Value | Verdict | Reason | Suggested change |
| ---- | ----------- | ------- | -------------------------------------------------------------------------------------- | ---------------- |
| — | No findings | — | The feedback change introduces no arbitrary CSS variables, colors, or Tailwind values. | — |

## D3 — Hardcoded Sizes / Colors

| Line | Value | Verdict | Reason | Suggested change |
| ---- | ----------- | ------- | ------------------------------------------------------------------- | ---------------- |
| — | No findings | — | The feedback change introduces no hardcoded size or color literals. | — |

## D4 — Accessibility

| Line | Element | Verdict | Reason | Suggested change |
| ---- | ------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| 178 | Detect `Button` | keep with reason | The design-system button has a visible localized label, native keyboard semantics, and is disabled while detection is active. | — |
| 230 | Detection feedback region | keep with reason | Progress and success use a polite status region; failures use an assertive alert region and retain a dismiss control. | — |

## D5 — Visual Patterns Observed

- No new repeated visual pattern. The implementation reuses the existing inline-alert feedback pattern.

## Summary

- 0 fixes recommended
- 2 kept with documented reason
- 0 abstract candidates (>= 3 occurrences)
1 change: 1 addition & 0 deletions src-tauri/crates/key-vault/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ reqwest = { workspace = true }
regex = "1.9.5"
log = "0.4"
tracing = "0.1.37"
libc = "0.2"
uuid = { version = "1", features = ["v4"] }
dirs = "6.0"
sha2 = "0.10"
Expand Down
10 changes: 5 additions & 5 deletions src-tauri/crates/key-vault/src/key_store/service/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,7 @@ impl KeyService {

/// Save or update a key
pub fn save_key(&self, key: ModelKey) -> Result<ModelKey, String> {
self.update_store(|store| {
let entry = key.clone();
store.set(key);
entry
})
self.update_store(|store| store.set(key))
}

/// Record behaviorally-observed reasoning capability for `model` on key
Expand Down Expand Up @@ -236,6 +232,10 @@ impl KeyService {
if let Some(enabled) = enabled_models {
entry.enabled_models = enabled;
}
// A successful model refresh is authoritative. Removed models
// must not survive in enabled_models when callers omit that
// optional field, and caller-provided lists are normalized too.
entry.normalize_enabled_models();
if let Some(quota) = quota_info {
entry.quota_info = Some(quota);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,11 @@ fn deserialize_key_store(contents: &str) -> Result<LoadedKeyStore, serde_json::E
}

match serde_json::from_value::<ModelKey>(raw.clone()) {
Ok(key) => {
Ok(mut key) => {
// Older catalog refreshes could leave removed model ids in
// enabled_models. Normalize on hydration so every reader sees
// a consistent account even before the next persisted write.
key.normalize_enabled_models();
store.keys.insert(storage_id, key);
}
Err(error) => invalid_credentials.push(InvalidStoredCredential {
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/crates/key-vault/src/key_store/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ impl KeyStore {
}

/// Save or update a key
pub fn set(&mut self, mut key: ModelKey) {
pub fn set(&mut self, mut key: ModelKey) -> ModelKey {
key.normalize_enabled_models();
key.updated_at = Utc::now();
let saved = key.clone();
self.keys.insert(key.id.clone(), key);
self.updated_at = Utc::now();
saved
}

/// Delete key by agent type and optional ID
Expand Down
82 changes: 82 additions & 0 deletions src-tauri/crates/key-vault/src/key_store/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,86 @@ fn test_key_crud() {
assert_eq!(empty.len(), 0);
}

#[test]
fn test_save_key_normalizes_enabled_models_to_available_catalog() {
let temp_dir = tempdir().unwrap();
let service = KeyService::new(Some(temp_dir.path().to_path_buf()));

let mut key = ModelKey::new(ModelType::Codex);
key.available_models = vec!["gpt-5.5".to_string(), "gpt-5.4".to_string()];
key.enabled_models = vec![
"gpt-5.6-sol".to_string(),
"gpt-5.5".to_string(),
"gpt-5.5".to_string(),
];

let saved = service.save_key(key).unwrap();

assert_eq!(saved.enabled_models, vec!["gpt-5.5".to_string()]);
assert_eq!(
service.get_key_by_id(&saved.id).unwrap().enabled_models,
vec!["gpt-5.5".to_string()]
);
let persisted: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(temp_dir.path().join("credentials.json")).unwrap(),
)
.unwrap();
assert_eq!(
persisted["credentials"][&saved.id]["enabled_models"],
serde_json::json!(["gpt-5.5"]),
"serialized credentials must not retain unavailable enabled models"
);
}

#[test]
fn test_load_normalizes_legacy_stale_enabled_models() {
let temp_dir = tempdir().unwrap();
let service = KeyService::new(Some(temp_dir.path().to_path_buf()));

let mut key = ModelKey::new(ModelType::Codex);
let key_id = key.id.clone();
key.available_models = vec!["gpt-5.5".to_string()];
key.enabled_models = vec!["gpt-5.6-sol".to_string(), "gpt-5.5".to_string()];

let mut raw_store = KeyStore::default();
raw_store.keys.insert(key_id.clone(), key);
std::fs::write(
temp_dir.path().join("credentials.json"),
serde_json::to_string_pretty(&raw_store).unwrap(),
)
.unwrap();

let loaded = service.get_key_by_id(&key_id).unwrap();
assert_eq!(loaded.enabled_models, vec!["gpt-5.5".to_string()]);
}

#[test]
fn test_model_refresh_removes_enabled_models_missing_from_new_catalog() {
let temp_dir = tempdir().unwrap();
let service = KeyService::new(Some(temp_dir.path().to_path_buf()));

let mut key = ModelKey::new(ModelType::Codex);
key.available_models = vec!["gpt-5.6-sol".to_string(), "gpt-5.5".to_string()];
key.enabled_models = key.available_models.clone();
let saved = service.save_key(key).unwrap();

service
.update_key_health(
&saved.id,
HealthStatus::Valid,
None,
Some(vec!["gpt-5.5".to_string()]),
None,
None,
None,
)
.unwrap();

let refreshed = service.get_key_by_id(&saved.id).unwrap();
assert_eq!(refreshed.available_models, vec!["gpt-5.5".to_string()]);
assert_eq!(refreshed.enabled_models, vec!["gpt-5.5".to_string()]);
}

#[test]
fn retired_gemini_cli_credentials_do_not_corrupt_the_vault() {
let temp_dir = tempdir().unwrap();
Expand Down Expand Up @@ -1296,6 +1376,7 @@ fn test_cross_type_env_zenmux_as_claude_code_uses_anthropic_endpoint() {

let mut zenmux_key = ModelKey::new(ModelType::ZenmuxApi);
zenmux_key.api_key = Some("sk-zenmux-test123".to_string());
zenmux_key.available_models = vec!["claude-sonnet-4-20250514".to_string()];
zenmux_key.enabled_models = vec!["claude-sonnet-4-20250514".to_string()];
let key_id = zenmux_key.id.clone();
service.save_key(zenmux_key).unwrap();
Expand Down Expand Up @@ -1350,6 +1431,7 @@ fn test_cross_type_env_atlascloud_as_claude_code_uses_anthropic_endpoint() {

let mut atlas_key = ModelKey::new(ModelType::AtlascloudApi);
atlas_key.api_key = Some("atlas-test-key".to_string());
atlas_key.available_models = vec!["zai-org/glm-5.1".to_string()];
atlas_key.enabled_models = vec!["zai-org/glm-5.1".to_string()];
// The stored /v1 URL is OpenAI-protocol; the Anthropic export must
// ignore it and use the bare host instead.
Expand Down
16 changes: 15 additions & 1 deletion src-tauri/crates/key-vault/src/key_store/types.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
// Deserializer is used by the flexible_datetime modules below
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;

// Custom serde for flexible datetime parsing (naive timestamps without timezone)
Expand Down Expand Up @@ -544,6 +544,20 @@ impl ModelKey {
}
}

/// Restore the persisted model-selection invariant after catalog changes:
/// every enabled model must still exist in the provider's available-model
/// catalog. Preserve user ordering while also removing duplicate rows.
///
/// This is deliberately owned by `ModelKey`, rather than individual UI or
/// runtime consumers, so old credentials and every write path converge on
/// the same representation.
pub fn normalize_enabled_models(&mut self) {
let available: HashSet<&str> = self.available_models.iter().map(String::as_str).collect();
let mut seen = HashSet::new();
self.enabled_models
.retain(|model| available.contains(model.as_str()) && seen.insert(model.clone()));
}

/// Whether this credential is a native OAuth account for the target CLI.
/// Cross-provider keys and native API keys must never enter OAuth retry,
/// token rotation, or OAuth health bookkeeping.
Expand Down
30 changes: 15 additions & 15 deletions src/engines/ChatPanel/blocks/CanvasInlineCard/TEST_CASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,27 @@

## Happy Path

| # | Steps | Expected Result |
|---|-------|-----------------|
| 1 | Ask the agent to generate an interactive canvas. | The Canvas card appears inline in ChatPanel without an intermediate generic gray loading bar. |
| 2 | Observe the WorkStation while the canvas tool call arrives. | WorkStation and ChatPanel show the same canvas payload for the same event. |
| 3 | Wait for the assistant's final prose message. | The inline Canvas card remains visible when the streaming message becomes historical. |
| # | Steps | Expected Result |
| --- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| 1 | Ask the agent to generate an interactive canvas. | The Canvas card appears inline in ChatPanel without an intermediate generic gray loading bar. |
| 2 | Observe the WorkStation while the canvas tool call arrives. | WorkStation and ChatPanel show the same canvas payload for the same event. |
| 3 | Wait for the assistant's final prose message. | The inline Canvas card remains visible when the streaming message becomes historical. |

## Edge Cases

| # | Scenario | Steps | Expected Result |
|---|----------|-------|-----------------|
| 1 | Cold renderer cache | Open a fresh app window and generate a canvas as the first tool call. | ChatPanel preloads the renderer while the agent works and renders the card through the synchronous cache path. |
| 2 | Rapid finalization | Make `render_inline_canvas` complete immediately before the final assistant message. | No visible empty handoff appears between the live preview and persisted event card. |
| 3 | Session switch | Generate a canvas in session A, switch to session B, then return. | Session B never shows session A's preview; session A hydrates its persisted card. |
| 4 | Multiple canvases | Generate two canvases in consecutive turns. | Each historical tool event owns one inline card and the latest WorkStation selection advances normally. |
| # | Scenario | Steps | Expected Result |
| --- | ------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| 1 | Cold renderer cache | Open a fresh app window and generate a canvas as the first tool call. | ChatPanel preloads the renderer while the agent works and renders the card through the synchronous cache path. |
| 2 | Rapid finalization | Make `render_inline_canvas` complete immediately before the final assistant message. | No visible empty handoff appears between the live preview and persisted event card. |
| 3 | Session switch | Generate a canvas in session A, switch to session B, then return. | Session B never shows session A's preview; session A hydrates its persisted card. |
| 4 | Multiple canvases | Generate two canvases in consecutive turns. | Each historical tool event owns one inline card and the latest WorkStation selection advances normally. |

## Error / Degraded States

| # | Scenario | Steps | Expected Result |
|---|----------|-------|-----------------|
| 1 | Renderer chunk fails to load | Simulate a dynamic-import failure. | The existing activity error boundary reports the render failure; WorkStation data remains intact. |
| 2 | Invalid or empty payload | Invoke the tool without displayable content. | The Canvas card shows its existing empty or failed state and does not remove unrelated messages. |
| # | Scenario | Steps | Expected Result |
| --- | ---------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 1 | Renderer chunk fails to load | Simulate a dynamic-import failure. | The existing activity error boundary reports the render failure; WorkStation data remains intact. |
| 2 | Invalid or empty payload | Invoke the tool without displayable content. | The Canvas card shows its existing empty or failed state and does not remove unrelated messages. |

## Accessibility

Expand Down
8 changes: 8 additions & 0 deletions src/features/TeamCollaboration/forkModelFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ describe("isModelRunnableLocally", () => {
expect(isModelRunnableLocally("gpt-5.6-sol", [makeKey()])).toBe(false);
});

it("rejects a stale enabled model removed from the available catalog", () => {
expect(
isModelRunnableLocally("gpt-5.6-sol", [
makeKey({ enabled_models: ["gpt-5.6-sol"] }),
])
).toBe(false);
});

it("rejects when the only matching key is disabled", () => {
expect(
isModelRunnableLocally("deepseek-v4-pro", [makeKey({ enabled: false })])
Expand Down
16 changes: 12 additions & 4 deletions src/features/TeamCollaboration/forkModelFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ export function isModelRunnableWithAccount(
): boolean {
const key = keys.find((candidate) => candidate.id === accountId);
if (!key || !keyIsUsable(key)) return false;
const available = new Set(key.available_models ?? []);
const enabled = new Set(key.enabled_models ?? []);
if (enabled.has(model)) return true;
if (available.has(model) && enabled.has(model)) return true;
return (key.model_variants ?? []).some(
(variant) => variant.model === model && enabled.has(variant.base_model)
(variant) =>
variant.model === model &&
available.has(variant.base_model) &&
enabled.has(variant.base_model)
);
}

Expand All @@ -36,10 +40,14 @@ export function isModelRunnableLocally(
if (isOrgiiTierModel(model)) return true;
return keys.some((key) => {
if (!keyIsUsable(key)) return false;
const available = new Set(key.available_models ?? []);
const enabled = new Set(key.enabled_models ?? []);
if (enabled.has(model)) return true;
if (available.has(model) && enabled.has(model)) return true;
return (key.model_variants ?? []).some(
(variant) => variant.model === model && enabled.has(variant.base_model)
(variant) =>
variant.model === model &&
available.has(variant.base_model) &&
enabled.has(variant.base_model)
);
});
}
Expand Down
6 changes: 4 additions & 2 deletions src/hooks/housekeeper/useHousekeeperConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ export function getHousekeeperModelCandidates(
): string[] {
if (!account) return [];
const candidates: string[] = [];
for (const model of account.enabledModels ?? [])
pushUnique(candidates, model);
const available = new Set(account.availableModels ?? []);
for (const model of account.enabledModels ?? []) {
if (available.has(model)) pushUnique(candidates, model);
}
for (const model of account.availableModels ?? [])
pushUnique(candidates, model);
for (const variant of account.modelVariants ?? []) {
Expand Down
21 changes: 21 additions & 0 deletions src/hooks/models/useModelAccountLookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ describe("accountHasModel", () => {
expect(accountHasModel(account, "claude-opus-4-8-high")).toBe(false);
});

it("rejects enabled ids that are no longer in the available catalog", () => {
const account = claudeAccount({
availableModels: ["claude-opus-4-7"],
enabledModels: ["claude-opus-4-8"],
});

expect(accountHasModel(account, "claude-opus-4-8")).toBe(false);
expect(accountHasModel(account, "claude-opus-4-8-high")).toBe(false);
});

it("rejects unknown ids and disabled accounts", () => {
expect(accountHasModel(claudeAccount(), "claude-opus-4-8-xhigh")).toBe(
false
Expand All @@ -74,4 +84,15 @@ describe("buildAccountLookup", () => {
const lookup = buildAccountLookup([claudeAccount({ enabledModels: [] })]);
expect(lookup.has("claude-opus-4-8-high")).toBe(false);
});

it("does not count stale enabled ids from a different account catalog", () => {
const stale = claudeAccount({
id: "stale-key",
availableModels: ["claude-opus-4-7"],
enabledModels: ["claude-opus-4-8"],
});
const lookup = buildAccountLookup([claudeAccount(), stale]);

expect(lookup.get("claude-opus-4-8")?.totalKeys).toBe(1);
});
});
Loading
Loading