From 8b1666f9f763b6e375b045bb729f9cea508da76d Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 28 Apr 2026 15:38:44 -0700 Subject: [PATCH] feat(goose2): add useActiveProjectTint hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure selector: active session → project → color (or null). Route gating lives at the application point in AppShell, not here. Unit-tested against four cases: no active session, no project on session, in- project, deleted project. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/useActiveProjectTint.test.ts | 124 ++++++++++++++++++ .../chat/hooks/useActiveProjectTint.ts | 21 +++ 2 files changed, 145 insertions(+) create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useActiveProjectTint.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/useActiveProjectTint.ts diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useActiveProjectTint.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useActiveProjectTint.test.ts new file mode 100644 index 0000000000..cdad47c89d --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useActiveProjectTint.test.ts @@ -0,0 +1,124 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; + +import { useActiveProjectTint } from "../useActiveProjectTint"; + +const baseSessionState = { + sessions: [], + activeSessionId: null, + isLoading: false, + hasHydratedSessions: true, + contextPanelOpenBySession: {}, + activeWorkspaceBySession: {}, +}; + +const baseProjectState = { + projects: [], + loading: false, + activeProjectId: null, +}; + +describe("useActiveProjectTint", () => { + beforeEach(() => { + useChatSessionStore.setState(baseSessionState); + useProjectStore.setState(baseProjectState); + }); + + it("returns null when there is no active session", () => { + const { result } = renderHook(() => useActiveProjectTint()); + expect(result.current).toBeNull(); + }); + + it("returns null when the active session has no projectId", () => { + useChatSessionStore.setState({ + ...baseSessionState, + sessions: [ + { + id: "s1", + title: "Chat", + providerId: "openai", + modelId: "gpt-4o", + modelName: "GPT-4o", + createdAt: "2026-04-28T00:00:00.000Z", + updatedAt: "2026-04-28T00:00:00.000Z", + messageCount: 0, + }, + ], + activeSessionId: "s1", + }); + + const { result } = renderHook(() => useActiveProjectTint()); + expect(result.current).toBeNull(); + }); + + it("returns the project's color when the active session is in-project", () => { + useProjectStore.setState({ + ...baseProjectState, + projects: [ + { + id: "p1", + name: "Blue project", + color: "#3b82f6", + description: "", + prompt: "", + icon: "", + preferredProvider: null, + preferredModel: null, + workingDirs: [], + useWorktrees: false, + order: 0, + archivedAt: null, + createdAt: "2026-04-28T00:00:00.000Z", + updatedAt: "2026-04-28T00:00:00.000Z", + artifactsDir: "", + }, + ], + }); + useChatSessionStore.setState({ + ...baseSessionState, + sessions: [ + { + id: "s1", + title: "Chat", + providerId: "openai", + modelId: "gpt-4o", + modelName: "GPT-4o", + createdAt: "2026-04-28T00:00:00.000Z", + updatedAt: "2026-04-28T00:00:00.000Z", + messageCount: 0, + projectId: "p1", + }, + ], + activeSessionId: "s1", + }); + + const { result } = renderHook(() => useActiveProjectTint()); + expect(result.current).toBe("#3b82f6"); + }); + + it("returns null when the active session references a deleted project", () => { + useChatSessionStore.setState({ + ...baseSessionState, + sessions: [ + { + id: "s1", + title: "Chat", + providerId: "openai", + modelId: "gpt-4o", + modelName: "GPT-4o", + createdAt: "2026-04-28T00:00:00.000Z", + updatedAt: "2026-04-28T00:00:00.000Z", + messageCount: 0, + projectId: "ghost-project", + }, + ], + activeSessionId: "s1", + }); + + const { result } = renderHook(() => useActiveProjectTint()); + expect(result.current).toBeNull(); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/useActiveProjectTint.ts b/ui/goose2/src/features/chat/hooks/useActiveProjectTint.ts new file mode 100644 index 0000000000..4495bff00f --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useActiveProjectTint.ts @@ -0,0 +1,21 @@ +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; + +/** + * Resolves the active chat session's project color. Returns the hex string + * if the active session is bound to a known project, otherwise null. The + * route gate (only-tint-when-on-chat-view) lives at the application point + * in AppShell, not here — this hook stays a pure session→project→color + * selector so it has one named place to test. + */ +export function useActiveProjectTint(): string | null { + const activeSessionId = useChatSessionStore((s) => s.activeSessionId); + const sessions = useChatSessionStore((s) => s.sessions); + const projects = useProjectStore((s) => s.projects); + + if (!activeSessionId) return null; + const session = sessions.find((s) => s.id === activeSessionId); + if (!session?.projectId) return null; + const project = projects.find((p) => p.id === session.projectId); + return project?.color ?? null; +}