Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d2f0c0e
feat(chat): add starred models to the agent model picker
NickTitle Sep 3, 2026
0ed3806
fix(chat): keep starred models visible in the compact picker list
NickTitle Sep 3, 2026
8811486
fix(chat): fit picker within min window width; hover-reveal stars wit…
NickTitle Sep 3, 2026
d8b07c5
fix(chat): render star action through shared Button with semantic sel…
NickTitle Sep 3, 2026
af9ffaa
fix(chat): store each starred model as its own localStorage entry
NickTitle Sep 3, 2026
34d27b3
fix(chat): toast when starred-model writes fail instead of swallowing
NickTitle Sep 3, 2026
b112d29
fix(chat): hide starred models the catalog no longer serves
NickTitle Sep 3, 2026
906ba71
fix(chat): scope star reveal state to each model row
NickTitle Sep 4, 2026
53aa113
fix(chat): clear model star reveal with explicit row state
NickTitle Sep 4, 2026
bc1cca2
fix(chat): remove trailing fade from model star reveal
NickTitle Sep 4, 2026
cf4cda8
fix(chat): restore a quick model star fade
NickTitle Sep 4, 2026
ddad0ca
Revert "fix(chat): restore a quick model star fade"
NickTitle Sep 4, 2026
2efce9c
fix(chat): fade model stars in without trailing on exit
NickTitle Sep 4, 2026
f0fbe13
fix(chat): make model star fade-in perceptible
NickTitle Sep 4, 2026
7d25d08
fix(chat): keep favorited model stars visible
NickTitle Sep 4, 2026
0bcc1f9
feat(chat): animate favorite model layout changes
NickTitle Sep 4, 2026
7d8dd78
feat(chat): stage star toggle animation around row movement
NickTitle Sep 4, 2026
8d892c3
fix(chat): keep favorites visible across agent changes
NickTitle Sep 4, 2026
2c9bfc5
fix(chat): show owning agent icons on cross-agent favorites
NickTitle Sep 4, 2026
eeecf35
fix(chat): stabilize favorite animations and picker layout
NickTitle Sep 4, 2026
34343e3
fix(chat): reduce favorite star motion to a half turn
NickTitle Sep 4, 2026
c1e5ffd
fix(chat): wrap selected model pill around star
NickTitle Sep 4, 2026
d63418c
fix(chat): simplify and refresh starred-model state
NickTitle Sep 4, 2026
6126f3c
fix(chat): clarify cross-agent favorite rows
NickTitle Sep 4, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ export function ConversationComposerCapability({
currentModel: controller.currentModelName ?? undefined,
currentExecutionTarget: controller.currentExecutionTarget,
availableModels: controller.availableModels,
favoriteModels: controller.favoriteModels,
modelsLoading: controller.modelsLoading,
modelStatusMessage: controller.modelStatusMessage,
onModelChange: controller.handleModelChange,
Expand Down
11 changes: 11 additions & 0 deletions src/features/chat/hooks/useAgentModelPickerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ export function useAgentModelPickerState({
() => getModelsForAgent(selectedAgentId),
[getModelsForAgent, selectedAgentId],
);
const favoriteModels = useMemo(
() =>
pickerAgents.flatMap((agent) =>
getModelsForAgent(agent.id).map((model) => ({
agentId: agent.id,
model,
})),
),
[getModelsForAgent, pickerAgents],
);

const providerIdsForSelectedAgent = useMemo(
() =>
Expand Down Expand Up @@ -211,6 +221,7 @@ export function useAgentModelPickerState({
selectedAgentId,
pickerAgents,
availableModels,
favoriteModels,
getModelsForAgent,
isModelInventoryAuthoritative,
modelsLoading,
Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/hooks/useChatSessionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,7 @@ export function useChatSessionController({
selectedAgentId,
pickerAgents,
availableModels,
favoriteModels,
getModelsForAgent,
modelsLoading,
modelStatusMessage,
Expand Down Expand Up @@ -3556,6 +3557,7 @@ export function useChatSessionController({
currentModelName: effectiveModelSelection?.name ?? null,
currentExecutionTarget: session?.executionTarget,
availableModels,
favoriteModels,
modelsLoading,
modelStatusMessage,
handleModelChange: handleModelChangeWithContextReset,
Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/hooks/useResolvedAgentModelPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ export function useResolvedAgentModelPicker({
const {
pickerAgents,
availableModels,
favoriteModels,
getModelsForAgent,
isModelInventoryAuthoritative,
modelsLoading,
Expand Down Expand Up @@ -816,6 +817,7 @@ export function useResolvedAgentModelPicker({
selectedAgentId,
pickerAgents,
availableModels,
favoriteModels,
getModelsForAgent,
modelsLoading,
modelStatusMessage,
Expand Down
70 changes: 70 additions & 0 deletions src/features/chat/hooks/useStarredModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useCallback, useSyncExternalStore } from "react";
import {
getStarredModelKeys,
modelStarKey,
STARRED_MODELS_ENTRY_PREFIX,
STARRED_MODELS_EVENT,
toggleModelStar,
} from "../lib/starredModels";

let cachedSnapshot: Set<string> | null = null;
const serverSnapshot = new Set<string>();

/** Invalidate the in-memory snapshot cache. Intended for tests. */
export function __resetStarredModelsCacheForTests(): void {
cachedSnapshot = null;
}

function getSnapshot(): Set<string> {
if (cachedSnapshot === null) {
cachedSnapshot = getStarredModelKeys();
}
return cachedSnapshot;
}

Comment thread
NickTitle marked this conversation as resolved.
function subscribe(callback: () => void): () => void {
const handleChange = () => {
cachedSnapshot = null;
callback();
};
const handleStorage = (event: StorageEvent) => {
// Star entries live under per-key storage, so any entry write or removal
// in another window changes the set. `key === null` covers localStorage
// clears.
if (
event.key === null ||
event.key.startsWith(STARRED_MODELS_ENTRY_PREFIX)
) {
handleChange();
}
};

window.addEventListener(STARRED_MODELS_EVENT, handleChange);
window.addEventListener("storage", handleStorage);
// No listener exists while the store has no subscribers. Invalidate after
// attaching both listeners so a remount rereads changes made during that gap.
cachedSnapshot = null;
return () => {
window.removeEventListener(STARRED_MODELS_EVENT, handleChange);
window.removeEventListener("storage", handleStorage);
};
}

export function useStarredModels() {
const starredKeys = useSyncExternalStore(
subscribe,
getSnapshot,
() => serverSnapshot,
);
const isStarred = useCallback(
(scopeId: string, modelId: string) =>
starredKeys.has(modelStarKey(scopeId, modelId)),
[starredKeys],
);
const toggleStar = useCallback(
(scopeId: string, modelId: string) => toggleModelStar(scopeId, modelId),
[],
);

return { isStarred, toggleStar, starredKeys };
}
102 changes: 102 additions & 0 deletions src/features/chat/lib/starredModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { toast } from "sonner";
import { i18n } from "@/shared/i18n";

export const STARRED_MODELS_ENTRY_PREFIX = "goose:starredModels:v1:entry:";
const STARRED_MODELS_ENTRY_VALUE = "1";
const STARRED_MODELS_CHANGED_EVENT = "goose:starred-models-changed";

type StarredModelSet = Set<string>;

export function modelStarKey(scopeId: string, modelId: string): string {
return JSON.stringify([scopeId, modelId]);
}

/** localStorage key of the single entry that records one starred model. */
export function starredModelStorageKey(starKey: string): string {
return STARRED_MODELS_ENTRY_PREFIX + encodeURIComponent(starKey);
}

function readStarredModels(): StarredModelSet {
if (typeof window === "undefined") {
return new Set();
}

try {
const storage = window.localStorage;
const starred = new Set<string>();
for (let i = 0; i < storage.length; i += 1) {
const storageKey = storage.key(i);
if (!storageKey?.startsWith(STARRED_MODELS_ENTRY_PREFIX)) {
continue;
}
try {
starred.add(
decodeURIComponent(
storageKey.slice(STARRED_MODELS_ENTRY_PREFIX.length),
),
);
} catch {
// Skip a malformed entry rather than dropping every star.
}
}
return starred;
} catch {
return new Set();
}
}

/**
* Write or clear exactly one star entry. Touching a single key (instead of
* rewriting an aggregate array) removes the cross-window read-modify-write
* race where concurrent toggles from different windows could drop each
* other's stars. Note that two windows toggling the same model at the same
* instant can still interleave; per-model state stays consistent either way.
*/
function persistStarEntry(starKey: string, starred: boolean): boolean {
if (typeof window === "undefined") {
return false;
}

try {
const storageKey = starredModelStorageKey(starKey);
if (starred) {
window.localStorage.setItem(storageKey, STARRED_MODELS_ENTRY_VALUE);
} else {
window.localStorage.removeItem(storageKey);
}
} catch {
Comment thread
NickTitle marked this conversation as resolved.
// The write did not land (storage unavailable or over quota). Tell the
// user instead of letting the toggle silently bounce back.
toast.error(i18n.t("chat:notifications.starredModelsPersistError"));
window.dispatchEvent(new CustomEvent(STARRED_MODELS_CHANGED_EVENT));
return false;
}

window.dispatchEvent(new CustomEvent(STARRED_MODELS_CHANGED_EVENT));
return true;
}

export function getStarredModelKeys(): StarredModelSet {
return readStarredModels();
}

export function toggleModelStar(scopeId: string, modelId: string): boolean {
if (typeof window === "undefined") {
return false;
}

const starKey = modelStarKey(scopeId, modelId);

try {
const starred =
window.localStorage.getItem(starredModelStorageKey(starKey)) !== null;
return persistStarEntry(starKey, !starred);
} catch {
// Storage is unavailable, so the toggle cannot be applied at all. The
// write path reports its own failures; report this one too.
toast.error(i18n.t("chat:notifications.starredModelsPersistError"));
return false;
}
}

export const STARRED_MODELS_EVENT = STARRED_MODELS_CHANGED_EVENT;
1 change: 1 addition & 0 deletions src/features/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export interface ChatInputAgentModelPicker {
currentModel?: string;
currentExecutionTarget?: SessionExecutionTarget;
availableModels?: ModelOption[];
favoriteModels?: Array<{ agentId: string; model: ModelOption }>;
modelsLoading?: boolean;
modelStatusMessage?: string | null;
onModelChange?: (modelId: string, model?: ModelOption) => void;
Expand Down
Loading
Loading