refresh views after assistant changes
This commit is contained in:
1 parent
8dd1d70900
commit
c81a3b9432
14 files changed
+343
-7
No files matched your search
@@ -101,7 +101,7 @@ Action rules:
|
||||
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create.
|
||||
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
|
||||
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
|
||||
- Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL.
|
||||
- Browser tools can navigate within Homarr, open existing Homarr UI, or refresh the active view after a completed change. Never navigate to an arbitrary external URL and never refresh before a mutation finishes.
|
||||
- Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON.
|
||||
- Use well-formed GitHub-flavored Markdown. Put each list item on its own line and leave a blank line before lists.
|
||||
- If configuration or a service is unavailable, say what the user or administrator can do next.
|
||||
|
||||
@@ -16,11 +16,13 @@ describe("assistant browser tool executors", () => {
|
||||
const navigate = vi.fn();
|
||||
const openCommandMenu = vi.fn();
|
||||
const openMediaRequestSearch = vi.fn();
|
||||
const refreshCurrentView = vi.fn().mockResolvedValue(undefined);
|
||||
const executors = createAssistantBrowserToolExecutors({
|
||||
getOrigin: () => "https://homarr.example",
|
||||
navigate,
|
||||
openCommandMenu,
|
||||
openMediaRequestSearch,
|
||||
refreshCurrentView,
|
||||
});
|
||||
|
||||
await expect(executors.navigate_to_route({ path: "/manage/apps" })).resolves.toEqual({
|
||||
@@ -29,11 +31,13 @@ describe("assistant browser tool executors", () => {
|
||||
});
|
||||
await expect(executors.open_command_menu()).resolves.toEqual({ success: true });
|
||||
await expect(executors.open_media_request_search()).resolves.toEqual({ success: true });
|
||||
await expect(executors.refresh_current_view()).resolves.toEqual({ success: true });
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/manage/apps");
|
||||
expect(openCommandMenu).toHaveBeenCalledOnce();
|
||||
expect(openMediaRequestSearch).toHaveBeenCalledOnce();
|
||||
expect(refreshCurrentView).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("does not navigate when the assistant requests an unsafe route", async () => {
|
||||
@@ -43,6 +47,7 @@ describe("assistant browser tool executors", () => {
|
||||
navigate,
|
||||
openCommandMenu: vi.fn(),
|
||||
openMediaRequestSearch: vi.fn(),
|
||||
refreshCurrentView: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await expect(executors.navigate_to_route({ path: "https://example.com" })).resolves.toEqual({
|
||||
@@ -51,4 +56,19 @@ describe("assistant browser tool executors", () => {
|
||||
});
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports a failed refresh without crashing the assistant run", async () => {
|
||||
const executors = createAssistantBrowserToolExecutors({
|
||||
getOrigin: () => "https://homarr.example",
|
||||
navigate: vi.fn(),
|
||||
openCommandMenu: vi.fn(),
|
||||
openMediaRequestSearch: vi.fn(),
|
||||
refreshCurrentView: vi.fn().mockRejectedValue(new Error("offline")),
|
||||
});
|
||||
|
||||
await expect(executors.refresh_current_view()).resolves.toEqual({
|
||||
success: false,
|
||||
error: "The current Homarr view could not be refreshed.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ interface AssistantBrowserToolDependencies {
|
||||
navigate: (path: string) => void;
|
||||
openCommandMenu: () => void;
|
||||
openMediaRequestSearch: () => void;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const resolveAssistantInternalRoute = (path: string, origin: string) => {
|
||||
@@ -19,6 +20,7 @@ export const createAssistantBrowserToolExecutors = ({
|
||||
navigate,
|
||||
openCommandMenu,
|
||||
openMediaRequestSearch,
|
||||
refreshCurrentView,
|
||||
}: AssistantBrowserToolDependencies) => ({
|
||||
navigate_to_route: async ({ path }: { path: string }) => {
|
||||
const internalPath = resolveAssistantInternalRoute(path, getOrigin());
|
||||
@@ -37,6 +39,14 @@ export const createAssistantBrowserToolExecutors = ({
|
||||
openMediaRequestSearch();
|
||||
return { success: true as const };
|
||||
},
|
||||
refresh_current_view: async () => {
|
||||
try {
|
||||
await refreshCurrentView();
|
||||
return { success: true as const };
|
||||
} catch {
|
||||
return { success: false as const, error: "The current Homarr view could not be refreshed." };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export type AssistantBrowserToolExecutors = ReturnType<typeof createAssistantBrowserToolExecutors>;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ThreadMessage } from "@assistant-ui/react";
|
||||
|
||||
import {
|
||||
getSuccessfulApprovedAssistantMutationIds,
|
||||
updateAssistantMutationRefreshState,
|
||||
} from "./assistant-mutation-refresh";
|
||||
|
||||
const assistantMessage = (...content: ThreadMessage["content"]): ThreadMessage =>
|
||||
({
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
content,
|
||||
status: { type: "complete", reason: "stop" },
|
||||
createdAt: new Date(),
|
||||
metadata: { custom: {} },
|
||||
}) as ThreadMessage;
|
||||
|
||||
describe("assistant mutation refresh", () => {
|
||||
test("returns only completed, approved mutation calls", () => {
|
||||
expect(
|
||||
getSuccessfulApprovedAssistantMutationIds([
|
||||
assistantMessage(
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "approved-1",
|
||||
toolName: "app_create",
|
||||
args: { name: "Wikipedia" },
|
||||
argsText: "{}",
|
||||
approval: { id: "approval-1", approved: true },
|
||||
result: { id: "app-1" },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "read-1",
|
||||
toolName: "app_getAll",
|
||||
args: {},
|
||||
argsText: "{}",
|
||||
result: [],
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "pending-1",
|
||||
toolName: "board_savePartialBoardSettings",
|
||||
args: { id: "board-1" },
|
||||
argsText: "{}",
|
||||
approval: { id: "approval-2", approved: true },
|
||||
},
|
||||
),
|
||||
]),
|
||||
).toEqual(["approved-1"]);
|
||||
});
|
||||
|
||||
test("ignores denied and failed mutations", () => {
|
||||
expect(
|
||||
getSuccessfulApprovedAssistantMutationIds([
|
||||
assistantMessage(
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "denied-1",
|
||||
toolName: "app_create",
|
||||
args: {},
|
||||
argsText: "{}",
|
||||
approval: { id: "approval-1", approved: false },
|
||||
result: null,
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "failed-1",
|
||||
toolName: "app_create",
|
||||
args: {},
|
||||
argsText: "{}",
|
||||
approval: { id: "approval-2", approved: true },
|
||||
result: { error: "Creation failed" },
|
||||
},
|
||||
),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("baselines loaded history, refreshes once for new calls, and baselines a switched conversation", () => {
|
||||
const initial = updateAssistantMutationRefreshState({ conversationId: null, toolCallIds: new Set() }, "thread-1", [
|
||||
"historical-1",
|
||||
]);
|
||||
expect(initial.shouldRefresh).toBe(false);
|
||||
|
||||
const mutation = updateAssistantMutationRefreshState(initial.state, "thread-1", ["historical-1", "new-1", "new-2"]);
|
||||
expect(mutation.shouldRefresh).toBe(true);
|
||||
expect(updateAssistantMutationRefreshState(mutation.state, "thread-1", ["new-1", "new-2"]).shouldRefresh).toBe(
|
||||
false,
|
||||
);
|
||||
expect(updateAssistantMutationRefreshState(mutation.state, "thread-2", ["historical-2"]).shouldRefresh).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ThreadMessage } from "@assistant-ui/react";
|
||||
|
||||
const resultHasError = (result: unknown) => typeof result === "object" && result !== null && "error" in result;
|
||||
|
||||
export const getSuccessfulApprovedAssistantMutationIds = (messages: readonly ThreadMessage[]) =>
|
||||
messages.flatMap((message) =>
|
||||
message.role === "assistant"
|
||||
? message.content.flatMap((part) =>
|
||||
part.type === "tool-call" &&
|
||||
part.approval?.approved === true &&
|
||||
part.result !== undefined &&
|
||||
part.isError !== true &&
|
||||
!resultHasError(part.result)
|
||||
? [part.toolCallId]
|
||||
: [],
|
||||
)
|
||||
: [],
|
||||
);
|
||||
|
||||
export interface AssistantMutationRefreshState {
|
||||
conversationId: string | null;
|
||||
toolCallIds: Set<string>;
|
||||
}
|
||||
|
||||
export const updateAssistantMutationRefreshState = (
|
||||
state: AssistantMutationRefreshState,
|
||||
conversationId: string,
|
||||
successfulMutationIds: readonly string[],
|
||||
) => {
|
||||
if (state.conversationId !== conversationId) {
|
||||
return {
|
||||
state: { conversationId, toolCallIds: new Set(successfulMutationIds) },
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
const toolCallIds = new Set(state.toolCallIds);
|
||||
let shouldRefresh = false;
|
||||
for (const toolCallId of successfulMutationIds) {
|
||||
if (toolCallIds.has(toolCallId)) continue;
|
||||
toolCallIds.add(toolCallId);
|
||||
shouldRefresh = true;
|
||||
}
|
||||
|
||||
return { state: { conversationId, toolCallIds }, shouldRefresh };
|
||||
};
|
||||
@@ -97,6 +97,7 @@ import remarkGfm from "remark-gfm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { assistantReasoningModes } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
@@ -114,6 +115,8 @@ export interface AssistantConversationControls {
|
||||
models: AssistantRuntimeModelOption[];
|
||||
modelOptionsLoading: boolean;
|
||||
reasoning: AssistantReasoningMode;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
|
||||
}
|
||||
@@ -1389,6 +1392,38 @@ const AutoApprovalControl = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ViewRefreshAction = ({
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
}: Pick<AssistantConversationControls, "isRefreshing" | "onRefresh">) => {
|
||||
const t = useScopedI18n("common.assistant.refresh");
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
await onRefresh();
|
||||
showSuccessNotification({ title: t("completeTitle"), message: t("completeDescription") });
|
||||
} catch {
|
||||
showErrorNotification({ title: t("failedTitle"), message: t("failedDescription") });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={isRefreshing ? t("working") : t("action")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
loading={isRefreshing}
|
||||
loaderProps={{ type: "bars" }}
|
||||
onClick={() => void refresh()}
|
||||
aria-label={isRefreshing ? t("working") : t("action")}
|
||||
>
|
||||
<IconRefresh size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyThread = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
@@ -2015,6 +2050,8 @@ export const AssistantConversationSurface = ({
|
||||
models,
|
||||
modelOptionsLoading,
|
||||
reasoning,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onModelChange,
|
||||
onReasoningChange,
|
||||
onExpand,
|
||||
@@ -2041,6 +2078,7 @@ export const AssistantConversationSurface = ({
|
||||
</Group>
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||
<AutoApprovalControl />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
@@ -2114,6 +2152,8 @@ export const AssistantConversationSurface = ({
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
pendingAction={pendingAction}
|
||||
@@ -2140,6 +2180,8 @@ export const AssistantPanel = ({
|
||||
models,
|
||||
modelOptionsLoading,
|
||||
reasoning,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onModelChange,
|
||||
onReasoningChange,
|
||||
}: AssistantPanelProps) => {
|
||||
@@ -2192,6 +2234,8 @@ export const AssistantPanel = ({
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onMinimize={onClose}
|
||||
|
||||
@@ -39,7 +39,12 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
|
||||
import type { AssistantUIMessage } from "./assistant-message-metadata";
|
||||
import {
|
||||
getSuccessfulApprovedAssistantMutationIds,
|
||||
updateAssistantMutationRefreshState,
|
||||
} from "./assistant-mutation-refresh";
|
||||
import { initialAssistantNotificationState, updateAssistantNotificationState } from "./assistant-notifications";
|
||||
import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh";
|
||||
import { AssistantBoardWidget } from "./assistant-widget";
|
||||
|
||||
interface AssistantContextValue {
|
||||
@@ -47,11 +52,13 @@ interface AssistantContextValue {
|
||||
unavailableDescription: string | null;
|
||||
opened: boolean;
|
||||
isRunning: boolean;
|
||||
isRefreshing: boolean;
|
||||
unreadCount: number;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
sendPrompt: (prompt: string) => void;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
@@ -408,6 +415,7 @@ const AssistantThreadRuntime = () => {
|
||||
|
||||
const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
const router = useRouter();
|
||||
const { refreshCurrentView } = useAssistantViewRefresh();
|
||||
const runtime = useRemoteThreadListRuntime({
|
||||
adapter: threadAdapter,
|
||||
runtimeHook: AssistantThreadRuntime,
|
||||
@@ -419,8 +427,9 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
navigate: (path) => router.push(path),
|
||||
openCommandMenu: openSpotlight,
|
||||
openMediaRequestSearch,
|
||||
refreshCurrentView,
|
||||
}),
|
||||
[router],
|
||||
[refreshCurrentView, router],
|
||||
);
|
||||
|
||||
const toolkit = useMemo(
|
||||
@@ -465,6 +474,12 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
execute: browserToolExecutors.open_media_request_search,
|
||||
renderText: { running: "Opening media search…", complete: "Media search opened" },
|
||||
},
|
||||
refresh_current_view: {
|
||||
type: "frontend",
|
||||
...browserToolContracts.refresh_current_view,
|
||||
execute: browserToolExecutors.refresh_current_view,
|
||||
renderText: { running: "Refreshing current view…", complete: "Current view refreshed" },
|
||||
},
|
||||
}) as Toolkit,
|
||||
[browserToolExecutors],
|
||||
);
|
||||
@@ -541,6 +556,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
||||
const aui = useAui();
|
||||
const preferences = useAssistantPreferences();
|
||||
const { isRefreshing, refreshCurrentView } = useAssistantViewRefresh();
|
||||
const messages = useAuiState((state) => state.thread.messages);
|
||||
// The local id is stable while a new thread is initialized and changes for every real thread switch.
|
||||
const conversationId = useAuiState((state) => state.threadListItem.id);
|
||||
@@ -555,6 +571,11 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const assistantIsRunning = isRunning || queuedPrompt !== null;
|
||||
const notificationKey = getNotificationKey(latestAssistantMessage);
|
||||
const notificationStateRef = useRef(initialAssistantNotificationState);
|
||||
const mutationRefreshStateRef = useRef<{ conversationId: string | null; toolCallIds: Set<string> }>({
|
||||
conversationId: null,
|
||||
toolCallIds: new Set(),
|
||||
});
|
||||
const successfulMutationIds = useMemo(() => getSuccessfulApprovedAssistantMutationIds(messages), [messages]);
|
||||
|
||||
const markRead = useCallback(() => {
|
||||
notificationStateRef.current = {
|
||||
@@ -633,6 +654,25 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
setUnreadCount((current) => current + 1);
|
||||
}, [conversationId, isLoading, notificationKey, opened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
const update = updateAssistantMutationRefreshState(
|
||||
mutationRefreshStateRef.current,
|
||||
conversationId,
|
||||
successfulMutationIds,
|
||||
);
|
||||
mutationRefreshStateRef.current = update.state;
|
||||
if (!update.shouldRefresh) return;
|
||||
|
||||
void refreshCurrentView().catch(() => {
|
||||
showWarningNotification({
|
||||
title: t("refresh.failedTitle"),
|
||||
message: t("refresh.failedDescription"),
|
||||
});
|
||||
});
|
||||
}, [conversationId, isLoading, refreshCurrentView, successfulMutationIds, t]);
|
||||
|
||||
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
|
||||
|
||||
const spotlightItem = useMemo(
|
||||
@@ -654,13 +694,15 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
unavailableDescription: null,
|
||||
opened,
|
||||
isRunning: assistantIsRunning,
|
||||
isRefreshing,
|
||||
unreadCount,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
sendPrompt,
|
||||
refreshCurrentView,
|
||||
}),
|
||||
[assistantIsRunning, close, open, opened, sendPrompt, toggle, unreadCount],
|
||||
[assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -686,6 +728,8 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
@@ -720,11 +764,13 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
|
||||
unavailableDescription: description,
|
||||
opened: false,
|
||||
isRunning: false,
|
||||
isRefreshing: false,
|
||||
unreadCount: 0,
|
||||
open: () => undefined,
|
||||
close: () => undefined,
|
||||
toggle: () => undefined,
|
||||
sendPrompt: () => undefined,
|
||||
refreshCurrentView: () => Promise.resolve(),
|
||||
}),
|
||||
[description],
|
||||
);
|
||||
@@ -748,9 +794,11 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
if (enabled) {
|
||||
return (
|
||||
<AssistantPreferencesProvider>
|
||||
<AssistantRuntime>
|
||||
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
|
||||
</AssistantRuntime>
|
||||
<AssistantViewRefreshProvider>
|
||||
<AssistantRuntime>
|
||||
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
|
||||
</AssistantRuntime>
|
||||
</AssistantViewRefreshProvider>
|
||||
</AssistantPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const supportedToolArguments = {
|
||||
navigate_to_route: { path: "/manage/apps" },
|
||||
open_command_menu: {},
|
||||
open_media_request_search: {},
|
||||
refresh_current_view: {},
|
||||
} satisfies Record<keyof typeof browserToolContracts, unknown>;
|
||||
|
||||
describe("assistant human tool contracts", () => {
|
||||
|
||||
@@ -100,6 +100,11 @@ export const browserToolContracts = {
|
||||
description: "Open Homarr's media request search interface.",
|
||||
parameters: z.object({}),
|
||||
},
|
||||
refresh_current_view: {
|
||||
description:
|
||||
"Refetch the active Homarr page and its visible dashboard data. Use when the user asks to reload, or when the current view still needs to reflect an already completed change. Never use this before a mutation is approved and completed.",
|
||||
parameters: z.object({}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AskUserArgs = z.infer<(typeof browserToolContracts)["ask_user"]["parameters"]>;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface AssistantViewRefreshContextValue {
|
||||
isRefreshing: boolean;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AssistantViewRefreshContext = createContext<AssistantViewRefreshContextValue | null>(null);
|
||||
|
||||
export const AssistantViewRefreshProvider = ({ children }: PropsWithChildren) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const refreshPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const refreshAgainRef = useRef(false);
|
||||
|
||||
const refreshCurrentView = useCallback(() => {
|
||||
if (refreshPromiseRef.current) {
|
||||
refreshAgainRef.current = true;
|
||||
return refreshPromiseRef.current;
|
||||
}
|
||||
|
||||
setIsRefreshing(true);
|
||||
const refreshPromise = (async () => {
|
||||
do {
|
||||
refreshAgainRef.current = false;
|
||||
router.refresh();
|
||||
await queryClient.invalidateQueries({ refetchType: "active" });
|
||||
} while (refreshAgainRef.current);
|
||||
})().finally(() => {
|
||||
refreshPromiseRef.current = null;
|
||||
setIsRefreshing(false);
|
||||
});
|
||||
refreshPromiseRef.current = refreshPromise;
|
||||
return refreshPromise;
|
||||
}, [queryClient, router]);
|
||||
|
||||
const value = useMemo(() => ({ isRefreshing, refreshCurrentView }), [isRefreshing, refreshCurrentView]);
|
||||
|
||||
return <AssistantViewRefreshContext.Provider value={value}>{children}</AssistantViewRefreshContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAssistantViewRefresh = () => {
|
||||
const value = useContext(AssistantViewRefreshContext);
|
||||
if (!value) throw new Error("useAssistantViewRefresh must be used within AssistantViewRefreshProvider");
|
||||
return value;
|
||||
};
|
||||
@@ -116,6 +116,8 @@ const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: Wid
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={assistant.isRefreshing}
|
||||
onRefresh={assistant.refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
onExpand={assistant.open}
|
||||
|
||||
Reference in new issue
Block a user