fix(site): keep workspace quota meter live-updated in Agents chat (#25468)

The workspace quota meter in the Agents chat sidebar previously only
refreshed on a full page reload, while the AI cost-usage meter rendered
next to it already polled every 60 seconds.

This change gives the Agents usage indicator the same 60s
workspace-quota polling and refreshes derived workspace caches
immediately when workspace-affecting chat tools complete.
`create_workspace`, `start_workspace`, and `stop_workspace` now
invalidate the workspace quota and `workspaces` query family, so both
the credit numbers and the workspace-count detail stay in sync.

`create_workspace` still invalidates the chat record to resolve
workspace bindings, and archive-and-delete uses the shared workspace
mutation invalidation helper so deleting an agent workspace refreshes
the same derived workspace data.

Closes CODAGT-444


Tested manually and it works perfectly.
This commit is contained in:
Ethan
2026-05-20 12:30:28 +10:00
committed by GitHub
parent 13bf0e11f1
commit 4c362499f2
10 changed files with 653 additions and 389 deletions
+165
View File
@@ -0,0 +1,165 @@
import { QueryClient } from "react-query";
import { describe, expect, it } from "vitest";
import type { WorkspacesResponse } from "#/api/typesGenerated";
import { getWorkspaceQuotaQueryKey } from "./workspaceQuota";
import {
autoCreateWorkspace,
buildLogsKey,
createWorkspace,
invalidateWorkspaceListQueries,
invalidateWorkspaceMutationQueries,
workspacesKey,
workspacesQueryKeyPrefix,
workspaceUsage,
} from "./workspaces";
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const workspacesResponse = {
workspaces: [],
count: 0,
} satisfies WorkspacesResponse;
const seedWorkspaceFamilyQueries = (queryClient: QueryClient) => {
const rawListKey = workspacesQueryKeyPrefix;
const defaultListKey = workspacesKey({});
const filteredListKey = workspacesKey({
q: "owner:me organization:default",
limit: 25,
offset: 50,
});
const usageKey = workspaceUsage({
usageApp: "reconnecting-pty",
connectionStatus: "connected",
workspaceId: "workspace-1",
agentId: "agent-1",
}).queryKey;
const buildLogs = buildLogsKey("workspace-1");
const workspacePermissionsKey = [
"workspaces",
"workspace-1",
"permissions",
] as const;
const workspaceAgentCredentialsKey = [
"workspaces",
"workspace-1",
"agents",
"main",
"credentials",
] as const;
const organizationWorkspacePermissionsKey = [
"workspaces",
["organization-1"],
"permissions",
] as const;
queryClient.setQueryData(rawListKey, workspacesResponse);
queryClient.setQueryData(defaultListKey, workspacesResponse);
queryClient.setQueryData(filteredListKey, workspacesResponse);
queryClient.setQueryData(usageKey, { tracked: true });
queryClient.setQueryData(buildLogs, []);
queryClient.setQueryData(workspacePermissionsKey, { read: true });
queryClient.setQueryData(workspaceAgentCredentialsKey, { token: "secret" });
queryClient.setQueryData(organizationWorkspacePermissionsKey, { read: true });
return {
listKeys: [rawListKey, defaultListKey, filteredListKey],
nonListKeys: [
usageKey,
buildLogs,
workspacePermissionsKey,
workspaceAgentCredentialsKey,
organizationWorkspacePermissionsKey,
],
};
};
describe("invalidateWorkspaceListQueries", () => {
it("invalidates workspace list queries without touching side-effecting workspace-family queries", async () => {
const queryClient = createTestQueryClient();
const { listKeys, nonListKeys } = seedWorkspaceFamilyQueries(queryClient);
await invalidateWorkspaceListQueries(queryClient);
for (const key of listKeys) {
expect(
queryClient.getQueryState(key)?.isInvalidated,
`${JSON.stringify(key)} should be invalidated`,
).toBe(true);
}
for (const key of nonListKeys) {
expect(
queryClient.getQueryState(key)?.isInvalidated,
`${JSON.stringify(key)} should NOT be invalidated`,
).not.toBe(true);
}
});
});
describe("invalidateWorkspaceMutationQueries", () => {
it("uses narrowed list invalidation and keeps workspace usage queries untouched", async () => {
const queryClient = createTestQueryClient();
const { listKeys, nonListKeys } = seedWorkspaceFamilyQueries(queryClient);
const quotaKey = getWorkspaceQuotaQueryKey("default", "me");
queryClient.setQueryData(quotaKey, { credits_consumed: 1, budget: 10 });
await invalidateWorkspaceMutationQueries(queryClient, {
organizationName: "default",
username: "me",
});
for (const key of listKeys) {
expect(
queryClient.getQueryState(key)?.isInvalidated,
`${JSON.stringify(key)} should be invalidated`,
).toBe(true);
}
expect(queryClient.getQueryState(quotaKey)?.isInvalidated).toBe(true);
for (const key of nonListKeys) {
expect(
queryClient.getQueryState(key)?.isInvalidated,
`${JSON.stringify(key)} should NOT be invalidated`,
).not.toBe(true);
}
});
});
describe("workspace creation mutations", () => {
it("use narrowed list invalidation for manual workspace creation", async () => {
const queryClient = createTestQueryClient();
const { listKeys, nonListKeys } = seedWorkspaceFamilyQueries(queryClient);
await createWorkspace(queryClient).onSuccess();
for (const key of listKeys) {
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
}
for (const key of nonListKeys) {
expect(queryClient.getQueryState(key)?.isInvalidated).not.toBe(true);
}
});
it("use narrowed list invalidation for auto workspace creation", async () => {
const queryClient = createTestQueryClient();
const { listKeys, nonListKeys } = seedWorkspaceFamilyQueries(queryClient);
await autoCreateWorkspace(queryClient).onSuccess();
for (const key of listKeys) {
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
}
for (const key of nonListKeys) {
expect(queryClient.getQueryState(key)?.isInvalidated).not.toBe(true);
}
});
});
+52 -3
View File
@@ -32,6 +32,9 @@ import {
import { checkAuthorization } from "./authCheck";
import { disabledRefetchOptions } from "./util";
import { workspaceBuildsKey } from "./workspaceBuilds";
import { getWorkspaceQuotaQueryKey } from "./workspaceQuota";
export const workspacesQueryKeyPrefix = ["workspaces"] as const;
export const workspaceByOwnerAndNameKey = (
ownerUsername: string,
@@ -126,7 +129,7 @@ export const createWorkspace = (queryClient: QueryClient) => {
return API.createWorkspace(userId, req);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["workspaces"] });
await invalidateWorkspaceListQueries(queryClient);
},
};
};
@@ -185,7 +188,7 @@ export const autoCreateWorkspace = (queryClient: QueryClient) => {
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["workspaces"] });
await invalidateWorkspaceListQueries(queryClient);
},
};
};
@@ -212,7 +215,7 @@ async function findMatchWorkspace(q: string): Promise<Workspace | undefined> {
}
export function workspacesKey(req: WorkspacesRequest = {}) {
return ["workspaces", req] as const;
return [...workspacesQueryKeyPrefix, req] as const;
}
export function workspaces(req: WorkspacesRequest = {}) {
@@ -222,6 +225,52 @@ export function workspaces(req: WorkspacesRequest = {}) {
} as const satisfies QueryOptions<WorkspacesResponse>;
}
const isWorkspacesListQuery = (query: {
queryKey: readonly unknown[];
}): boolean => {
const key = query.queryKey;
if (key.length === 1) {
return true;
}
if (key.length !== 2) {
return false;
}
const segment = key[1];
return (
segment !== null && typeof segment === "object" && !Array.isArray(segment)
);
};
export const invalidateWorkspaceListQueries = (queryClient: QueryClient) => {
return queryClient.invalidateQueries({
queryKey: workspacesQueryKeyPrefix,
predicate: isWorkspacesListQuery,
});
};
interface WorkspaceMutationInvalidationOptions {
organizationName: string;
username: string;
}
export async function invalidateWorkspaceMutationQueries(
queryClient: QueryClient,
{ organizationName, username }: WorkspaceMutationInvalidationOptions,
): Promise<void> {
const invalidations = [invalidateWorkspaceListQueries(queryClient)];
if (organizationName !== "") {
invalidations.push(
queryClient.invalidateQueries({
queryKey: getWorkspaceQuotaQueryKey(organizationName, username),
exact: true,
}),
);
}
await Promise.all(invalidations);
}
export const updateDeadline = (
workspace: Workspace,
): UseMutationOptions<void, unknown, Dayjs> => {
@@ -12,3 +12,7 @@ export const useDashboard = (): DashboardValue => {
return context;
};
export const getDefaultOrganizationName = (
organizations: DashboardValue["organizations"],
): string => organizations.find((org) => org.is_default)?.name ?? "";
+12 -4
View File
@@ -47,6 +47,10 @@ import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessagePart } from "#/api/typesGenerated";
import { useProxy } from "#/contexts/ProxyContext";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
getDefaultOrganizationName,
useDashboard,
} from "#/modules/dashboard/useDashboard";
import { isMobileViewport } from "#/utils/mobile";
import { pageTitle } from "#/utils/page";
import { rewriteLocalhostURL } from "#/utils/portForward";
@@ -71,7 +75,7 @@ import {
useChatSelector,
useChatStore,
} from "./components/ChatConversation/chatStore";
import { useWorkspaceCreationWatcher } from "./components/ChatConversation/useWorkspaceCreationWatcher";
import { useChatToolInvalidations } from "./components/ChatConversation/useChatToolInvalidations";
import type { PendingAttachment } from "./components/ChatPageContent";
import {
getDefaultMCPSelection,
@@ -667,6 +671,8 @@ const AgentChatPage: FC = () => {
} = useOutletContext<AgentsOutletContext>();
const queryClient = useQueryClient();
const { permissions, user: currentUser } = useAuthenticated();
const { organizations } = useDashboard();
const organizationName = getDefaultOrganizationName(organizations);
const [selectedModel, setSelectedModel] = useState("");
const scrollToBottomRef = useRef<(() => void) | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
@@ -1003,11 +1009,13 @@ const AgentChatPage: FC = () => {
agentStatus: workspaceAgent?.status,
});
// Detect workspace creation so the sidebar can resolve the
// workspace and display agent/git info.
useWorkspaceCreationWatcher({
// Detect completed chat tool results so sidebar data stays in sync
// with the server state those tools may have changed.
useChatToolInvalidations({
store,
chatID: agentId,
organizationName,
username: currentUser.username,
});
const handleCommit = (repoRoot: string) => {
+15 -2
View File
@@ -34,11 +34,18 @@ import {
updateInfiniteChatsCache,
userChatPersonalModelOverrides,
} from "#/api/queries/chats";
import { workspaceById } from "#/api/queries/workspaces";
import {
invalidateWorkspaceMutationQueries,
workspaceById,
} from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
getDefaultOrganizationName,
useDashboard,
} from "#/modules/dashboard/useDashboard";
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
import { AgentsPageView } from "./AgentsPageView";
import { emptyInputStorageKey } from "./components/AgentCreateForm";
@@ -66,7 +73,9 @@ const AgentsPage: FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { agentId } = useParams();
const { permissions } = useAuthenticated();
const { permissions, user } = useAuthenticated();
const { organizations } = useDashboard();
const organizationName = getDefaultOrganizationName(organizations);
const isAgentsAdmin = permissions.editDeploymentConfig;
const [archivedFilter, setArchivedFilter] = useArchivedFilterParam();
@@ -200,6 +209,10 @@ const AgentsPage: FC = () => {
await queryClient.invalidateQueries({
queryKey: chatsByWorkspaceKeyPrefix,
});
await invalidateWorkspaceMutationQueries(queryClient, {
organizationName,
username: user.username,
});
},
onError: (error) => {
toast.error(
@@ -0,0 +1,297 @@
import { renderHook, waitFor } from "@testing-library/react";
import type { FC, PropsWithChildren } from "react";
import { act } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getWorkspaceQuotaQueryKey } from "#/api/queries/workspaceQuota";
import { workspacesQueryKeyPrefix } from "#/api/queries/workspaces";
import { createChatStore } from "./chatStore";
import type { StreamState } from "./types";
import { useChatToolInvalidations } from "./useChatToolInvalidations";
const ORGANIZATION_NAME = "coder";
const USERNAME = "alice";
type ToolResultOverrides = Partial<StreamState["toolResults"][string]>;
const createStreamState = (
name: string,
id = "tool-1",
resultOverrides: ToolResultOverrides = {},
): StreamState => ({
blocks: [],
toolCalls: {
[id]: {
id,
name,
args: {},
},
},
toolResults: {
[id]: {
id,
name,
isError: false,
...resultOverrides,
},
},
sources: [],
});
const createTestStore = (initial: StreamState | null = null) => {
const store = createChatStore();
store.setStreamState(initial);
return {
store,
setStreamState: store.setStreamState,
};
};
const createWrapper = (queryClient: QueryClient): FC<PropsWithChildren> => {
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("useChatToolInvalidations", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
});
const renderInvalidations = ({
chatID = "chat-1",
organizationName = ORGANIZATION_NAME,
username = USERNAME,
}: {
chatID?: string;
organizationName?: string;
username?: string;
} = {}) => {
const { store, setStreamState } = createTestStore();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const result = renderHook(
(props: { chatID: string; organizationName: string; username: string }) =>
useChatToolInvalidations({
store,
...props,
}),
{
initialProps: { chatID, organizationName, username },
wrapper: createWrapper(queryClient),
},
);
return {
...result,
invalidateSpy,
setStreamState,
};
};
it("dispatches chat binding and workspace mutation invalidations on create_workspace completion", async () => {
const { invalidateSpy, setStreamState } = renderInvalidations();
await act(async () => {
setStreamState(createStreamState("create_workspace"));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["chats", "chat-1"],
});
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: workspacesQueryKeyPrefix,
predicate: expect.any(Function),
}),
);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: getWorkspaceQuotaQueryKey(ORGANIZATION_NAME, USERNAME),
exact: true,
});
expect(invalidateSpy).toHaveBeenCalledTimes(3);
});
});
it("dispatches workspace mutation invalidations on start_workspace completion", async () => {
const { invalidateSpy, setStreamState } = renderInvalidations();
await act(async () => {
setStreamState(createStreamState("start_workspace"));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: workspacesQueryKeyPrefix,
predicate: expect.any(Function),
}),
);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: getWorkspaceQuotaQueryKey(ORGANIZATION_NAME, USERNAME),
exact: true,
});
expect(invalidateSpy).toHaveBeenCalledTimes(2);
});
expect(invalidateSpy).not.toHaveBeenCalledWith({
queryKey: ["chats", "chat-1"],
});
});
it("dispatches workspace mutation invalidations for errored workspace tools", async () => {
const { invalidateSpy, setStreamState } = renderInvalidations();
await act(async () => {
setStreamState(
createStreamState("stop_workspace", "tool-1", { isError: true }),
);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: workspacesQueryKeyPrefix,
predicate: expect.any(Function),
}),
);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: getWorkspaceQuotaQueryKey(ORGANIZATION_NAME, USERNAME),
exact: true,
});
expect(invalidateSpy).toHaveBeenCalledTimes(2);
});
});
it("invalidates workspace queries without quota when the quota key is incomplete", async () => {
const { invalidateSpy, setStreamState } = renderInvalidations({
organizationName: "",
});
await act(async () => {
setStreamState(createStreamState("create_workspace"));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["chats", "chat-1"],
});
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: workspacesQueryKeyPrefix,
predicate: expect.any(Function),
}),
);
expect(invalidateSpy).toHaveBeenCalledTimes(2);
});
});
it("does not invalidate queries for tools without invalidation signals", async () => {
const { invalidateSpy, setStreamState } = renderInvalidations();
await act(async () => {
setStreamState(createStreamState("read_file"));
});
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("does not process the same tool call ID twice", async () => {
const { invalidateSpy, setStreamState } = renderInvalidations();
const streamState = createStreamState("create_workspace");
await act(async () => {
setStreamState(streamState);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(3);
});
await act(async () => {
setStreamState(streamState);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(3);
});
});
it("resets processed tool call IDs when chatID changes", async () => {
const { invalidateSpy, rerender, setStreamState } = renderInvalidations();
await act(async () => {
setStreamState(createStreamState("create_workspace"));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(3);
});
rerender({
chatID: "chat-2",
organizationName: ORGANIZATION_NAME,
username: USERNAME,
});
await act(async () => {
setStreamState(createStreamState("create_workspace"));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(6);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["chats", "chat-2"],
});
});
});
it("waits for completed tool results before invalidating", async () => {
const { invalidateSpy, setStreamState } = renderInvalidations();
await act(async () => {
setStreamState(
createStreamState("create_workspace", "tool-1", {
isStreaming: true,
}),
);
});
expect(invalidateSpy).not.toHaveBeenCalled();
await act(async () => {
setStreamState(createStreamState("create_workspace"));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(3);
});
});
it("does nothing when streamState is null", () => {
const { store } = createTestStore(null);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
renderHook(
() =>
useChatToolInvalidations({
store,
chatID: "chat-1",
organizationName: ORGANIZATION_NAME,
username: USERNAME,
}),
{ wrapper: createWrapper(queryClient) },
);
expect(invalidateSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,102 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "react-query";
import { chatKey } from "#/api/queries/chats";
import { invalidateWorkspaceMutationQueries } from "#/api/queries/workspaces";
import { type ChatStore, useChatSelector } from "./chatStore";
import type { StreamState } from "./types";
type ChatToolResult = Pick<
StreamState["toolResults"][string],
"id" | "name" | "isStreaming"
>;
// Only extract the toolResults record from the stream state.
// This reference is stable during pure text/thinking streaming
// and only changes when a tool result actually appears, avoiding
// a re-render of AgentChatPage on every token.
const selectStreamToolResults = (state: {
streamState: StreamState | null;
}): Record<string, ChatToolResult> | null =>
state.streamState?.toolResults ?? null;
interface UseChatToolInvalidationsOptions {
store: ChatStore;
chatID: string | undefined;
organizationName: string;
username: string;
}
const CHAT_WORKSPACE_BINDING_TOOL_NAMES = new Set(["create_workspace"]);
const WORKSPACE_MUTATION_TOOL_NAMES = new Set([
"create_workspace",
"start_workspace",
"stop_workspace",
]);
/**
* Watches completed chat tool results and invalidates derived UI data for the
* server state those tools may have changed.
*/
export function useChatToolInvalidations({
store,
chatID,
organizationName,
username,
}: UseChatToolInvalidationsOptions): void {
const queryClient = useQueryClient();
const toolResults = useChatSelector(store, selectStreamToolResults);
const processedToolCallIdsRef = useRef<Set<string>>(new Set());
const chatIDRef = useRef(chatID);
useEffect(() => {
if (chatIDRef.current !== chatID) {
chatIDRef.current = chatID;
processedToolCallIdsRef.current.clear();
}
if (!toolResults || !chatID) {
processedToolCallIdsRef.current.clear();
return;
}
let shouldInvalidateChat = false;
let shouldInvalidateWorkspace = false;
for (const toolResult of Object.values(toolResults)) {
if (
toolResult.isStreaming ||
processedToolCallIdsRef.current.has(toolResult.id)
) {
continue;
}
const changesChatWorkspaceBinding = CHAT_WORKSPACE_BINDING_TOOL_NAMES.has(
toolResult.name,
);
const changesWorkspace = WORKSPACE_MUTATION_TOOL_NAMES.has(
toolResult.name,
);
if (!changesChatWorkspaceBinding && !changesWorkspace) {
continue;
}
processedToolCallIdsRef.current.add(toolResult.id);
shouldInvalidateChat =
shouldInvalidateChat || changesChatWorkspaceBinding;
shouldInvalidateWorkspace = shouldInvalidateWorkspace || changesWorkspace;
}
if (shouldInvalidateChat) {
void queryClient.invalidateQueries({
queryKey: chatKey(chatID),
});
}
if (shouldInvalidateWorkspace) {
void invalidateWorkspaceMutationQueries(queryClient, {
organizationName,
username,
});
}
}, [chatID, organizationName, queryClient, toolResults, username]);
}
@@ -1,301 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import type { FC, PropsWithChildren } from "react";
import { act } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamState } from "./types";
import { useWorkspaceCreationWatcher } from "./useWorkspaceCreationWatcher";
type ChatStoreHandle = Parameters<
typeof useWorkspaceCreationWatcher
>[0]["store"];
const createStreamState = (
toolCalls: StreamState["toolCalls"],
toolResults: StreamState["toolResults"] = {},
): StreamState => ({
blocks: [],
toolCalls,
toolResults,
sources: [],
});
type MinimalChatStoreState = Pick<
ReturnType<ChatStoreHandle["getSnapshot"]>,
"streamState"
>;
const createTestStore = (initial: StreamState | null = null) => {
let state: MinimalChatStoreState = { streamState: initial };
const listeners = new Set<() => void>();
const store: Pick<ChatStoreHandle, "getSnapshot" | "subscribe"> = {
getSnapshot: () => state as ReturnType<ChatStoreHandle["getSnapshot"]>,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
return {
store: store as ChatStoreHandle,
setStreamState: (streamState: StreamState | null) => {
state = { streamState };
for (const listener of listeners) {
listener();
}
},
};
};
const createWrapper = (queryClient: QueryClient): FC<PropsWithChildren> => {
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("useWorkspaceCreationWatcher", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
});
it("invalidates chatKey on create_workspace tool result", async () => {
const { store, setStreamState } = createTestStore();
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
},
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["chats", "chat-1"],
});
});
invalidateSpy.mockRestore();
});
it("does not invalidate chat for non-workspace tools", async () => {
const { store, setStreamState } = createTestStore();
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "read_file",
args: { path: "/workspace/src/main.ts" },
},
},
{
"tool-1": {
id: "tool-1",
name: "read_file",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).not.toHaveBeenCalled();
});
invalidateSpy.mockRestore();
});
it("does not process the same tool call ID twice", async () => {
const { store, setStreamState } = createTestStore();
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const toolCalls = {
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
};
const toolResults = {
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
};
await act(async () => {
setStreamState(createStreamState(toolCalls, toolResults));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(1);
});
// Re-emit the same stream state (simulates a re-render).
await act(async () => {
setStreamState(createStreamState(toolCalls, toolResults));
});
// invalidateQueries should still have been called only once.
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(1);
});
invalidateSpy.mockRestore();
});
it("resets processed tool call IDs when chatID changes", async () => {
const { store, setStreamState } = createTestStore();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { rerender } = renderHook(
({ chatID }: { chatID: string }) =>
useWorkspaceCreationWatcher({
store,
chatID,
}),
{
initialProps: { chatID: "chat-1" },
wrapper: createWrapper(queryClient),
},
);
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
},
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(1);
});
// Switch to a new chat and emit the same tool call ID.
rerender({ chatID: "chat-2" });
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
},
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(2);
});
invalidateSpy.mockRestore();
});
it("does nothing when streamState is null", async () => {
const { store } = createTestStore(null);
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
await waitFor(() => {
expect(invalidateSpy).not.toHaveBeenCalled();
});
invalidateSpy.mockRestore();
});
});
@@ -1,76 +0,0 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "react-query";
import { chatKey } from "#/api/queries/chats";
import { useChatSelector } from "./chatStore";
import type { StreamState } from "./types";
type ChatStoreHandle = Parameters<typeof useChatSelector>[0];
// Only extract the toolResults record from the stream state.
// This reference is stable during pure text/thinking streaming
// and only changes when a tool result actually appears, avoiding
// a re-render of AgentChatPage on every token.
const selectStreamToolResults = (state: {
streamState: StreamState | null;
}): Record<string, { id: string; name: string }> | null =>
state.streamState?.toolResults ?? null;
interface UseWorkspaceCreationWatcherOptions {
store: ChatStoreHandle;
chatID: string | undefined;
}
// Triggers chat query invalidation to resolve the workspace/agent.
const WORKSPACE_TOOL_NAMES = new Set(["create_workspace"]);
/**
* Watches stream tool results for create_workspace completions and
* invalidates the chat query so the sidebar can display workspace info.
* The agent now handles all path discovery and scan triggering via
* the PathStore — no frontend refresh needed.
*/
export function useWorkspaceCreationWatcher({
store,
chatID,
}: UseWorkspaceCreationWatcherOptions): void {
const queryClient = useQueryClient();
const toolResults = useChatSelector(store, selectStreamToolResults);
const processedToolCallIdsRef = useRef<Set<string>>(new Set());
const chatIDRef = useRef(chatID);
// Watch stream tool results for create_workspace completions.
useEffect(() => {
// Reset processed IDs when chatID changes.
if (chatIDRef.current !== chatID) {
chatIDRef.current = chatID;
processedToolCallIdsRef.current = new Set();
}
if (!toolResults || !chatID) {
processedToolCallIdsRef.current.clear();
return;
}
let shouldInvalidateChat = false;
for (const toolResult of Object.values(toolResults)) {
if (processedToolCallIdsRef.current.has(toolResult.id)) {
continue;
}
if (WORKSPACE_TOOL_NAMES.has(toolResult.name)) {
processedToolCallIdsRef.current.add(toolResult.id);
shouldInvalidateChat = true;
}
}
if (shouldInvalidateChat) {
// Invalidate chatKey to trigger the workspace resolution
// cascade: chat refetch → workspaceId → workspace query →
// agent resolved → git watcher connects.
void queryClient.invalidateQueries({
queryKey: chatKey(chatID),
});
}
}, [toolResults, queryClient, chatID]);
}
@@ -20,7 +20,10 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import {
getDefaultOrganizationName,
useDashboard,
} from "#/modules/dashboard/useDashboard";
import { cn } from "#/utils/cn";
import { formatCostMicros } from "#/utils/currency";
import { getUsageLimitPeriodLabel } from "./ChatCostSummaryView";
@@ -46,11 +49,11 @@ export const UsageIndicator: FC = () => {
);
const { user } = useAuthenticated();
const { organizations } = useDashboard();
const organizationName =
organizations.find((org) => org.is_default)?.name ?? "";
const organizationName = getDefaultOrganizationName(organizations);
const username = user.username;
const { data: quota, isError: isQuotaError } = useQuery({
...workspaceQuota(organizationName, username),
refetchInterval: 60_000,
enabled: organizationName !== "" && username !== "",
});
const hasWorkspaceQuotaUsage =