refresh views after assistant changes
This commit is contained in:
1 parent
94ec244fad
commit
fc07e911d1
14 files changed
+343
-7
No files matched your search
@@ -173,9 +173,14 @@ modify permission on the selected board. The empty conversation includes an **Im
|
||||
and `/style` prepares the same guided workflow from the composer.
|
||||
|
||||
Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to
|
||||
safe internal routes and open Homarr interfaces such as the command menu and media request search.
|
||||
safe internal routes, refresh the current view, and open Homarr interfaces such as the command menu and media request search.
|
||||
They never receive provider credentials and cannot navigate to arbitrary external URLs.
|
||||
|
||||
After an approved mutation completes, Homarr automatically refetches active page and dashboard data so
|
||||
board items, settings, custom CSS, apps, and other visible results update without a full browser reload.
|
||||
The refresh action in the conversation header performs the same safe refetch manually. It keeps the
|
||||
conversation open while refreshing both client queries and server-rendered route data.
|
||||
|
||||
:::warning
|
||||
|
||||
Assistant prompts and selected tool results are sent to the configured model endpoint. Review that
|
||||
|
||||
@@ -51,6 +51,7 @@ active conversation silently: select **Open conversation** inside the widget bef
|
||||
- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing,
|
||||
speed, provider routing, and cost.
|
||||
- Change the model and reasoning effort for the active conversation.
|
||||
- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically.
|
||||
|
||||
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
|
||||
background when the widget leaves the viewport or the expanded panel is minimized.
|
||||
@@ -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}
|
||||
|
||||
@@ -2139,6 +2139,14 @@
|
||||
"dismiss": "Dismiss assistant activity"
|
||||
},
|
||||
"scrollToLatest": "Scroll to latest",
|
||||
"refresh": {
|
||||
"action": "Refresh current view",
|
||||
"working": "Refreshing current view",
|
||||
"completeTitle": "View refreshed",
|
||||
"completeDescription": "Visible Homarr data has been reloaded.",
|
||||
"failedTitle": "View could not be refreshed",
|
||||
"failedDescription": "The change may still be saved. Try the refresh action again."
|
||||
},
|
||||
"askUser": {
|
||||
"preparing": "Preparing question",
|
||||
"answered": "Your answer",
|
||||
|
||||
Reference in new issue
Block a user