diff --git a/.github/workflows/workshop.yml b/.github/workflows/workshop.yml index ec6d8d9dc..c924e8d80 100644 --- a/.github/workflows/workshop.yml +++ b/.github/workflows/workshop.yml @@ -23,8 +23,9 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @homarr/workshop typecheck - run: pnpm --filter @homarr/docs typecheck + - run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet - run: pnpm test:workshop - - run: docker build --target production -f apps/workshop/Dockerfile . + - run: pnpm test:workshop-image publish: if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main') diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index 9caa898a5..e3afc40b0 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -112,8 +112,8 @@ The advanced deployments environment variables should only be used if you know w | Environment Variable | Description | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. | -| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. | -| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. | +| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. | +| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. | All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index ff0d83af5..db5115118 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -148,12 +148,15 @@ moving through long lists. The selector below the composer combines searchable, their description, context size and image-input capability, plus the thinking-level control. Model selection is stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning setting and -still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider -streams it through assistant-ui's compact chain-of-thought disclosure. The **Thinking** disclosure renders -Markdown, can be opened or closed without interrupting the response, and keeps the user's collapsed state when -another message starts. A compact dot matrix distinguishes an initial thinking pass from response streaming and -settles into waiting, success, or error states in the minimized activity bar. The state always keeps its text label, -and the animation is disabled when the operating system requests reduced motion. +still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider streams +it through assistant-ui's grouped-parts and chain-of-thought primitives. The compact **Thinking** trace groups +adjacent reasoning with ordinary tool calls, shows each tool's status, duration and safe human-readable target, +and expands raw input, output and result previews only on request. Approval and question tools remain full-width +outside the trace. The disclosure renders Markdown, can be opened or closed without interrupting the response, +and keeps the user's collapsed state when another message starts. A compact dot matrix distinguishes an initial +thinking pass from response streaming and settles into waiting, success, or error states in the minimized activity +bar. The state always keeps its text label, and the animation is disabled when the operating system requests +reduced motion. A circular context ring beside the model selector gives a quick glance at cumulative conversation usage without expanding the composer toolbar. Its accessible label includes cumulative tokens and cost when available, while @@ -161,7 +164,8 @@ its popover keeps the latest complete context reading as a new response starts a turn with its model, provider, input, output, cached-input and reasoning tokens, context use, duration, speed, agent steps, routing details, and cost when reported. Before usage is available, the control remains a compact ring instead of showing a redundant missing-data label. Closing and reopening the popover reads persisted -conversation metadata instead of resetting the display to zero. +conversation metadata instead of resetting the display to zero. A larger ring inside the popover breaks the latest +reported context into used, remaining and total capacity plus input, cached-input, output and reasoning tokens. Each assistant response places a small provider logo in the same stable action toolbar as copy, export, feedback, and regenerate. Hovering the logo shows the provider, exact model, timing, throughput, and cost without diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 7caffcd42..e9d4acd2c 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -108,8 +108,45 @@ The public URL contract is: | `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production | | `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community | -The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. Restart -services after changing these values. +The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The +combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts; +the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it. + +### Staging and demo deployment + +Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2 +preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is: + +```dotenv +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* +``` + +Start or update it from the repository root: + +```sh +docker compose --env-file apps/workshop/.env.staging \ + -f apps/workshop/docker-compose.staging.yml pull +docker compose --env-file apps/workshop/.env.staging \ + -f apps/workshop/docker-compose.staging.yml up --detach +curl --fail http://127.0.0.1:8090/api/health +``` + +The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including +`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading. +This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the +chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep +`/workshop` only on `WORKSHOP_WEB_URL`. + +The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable +for previews; use the `sha-` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows +self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead. +GitHub OAuth requires both credentials and the callback +`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health +endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview. Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 13040b826..895dab602 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -12,6 +12,7 @@ if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) { const publicUrls = resolveHomarrUrlConfig({ homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL, workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl, + workshopWebUrl: process.env.WORKSHOP_WEB_URL, }); const config: Config = { title: "Homarr documentation", @@ -24,7 +25,9 @@ const config: Config = { projectName: "homarr", customFields: { workshopUrl: publicUrls.workshopApiUrl, + workshopWebUrl: publicUrls.workshopWebUrl, }, + scripts: [{ src: "/workshop-runtime-config.js" }], i18n: { defaultLocale: "en", locales: ["en"], diff --git a/apps/docs/src/components/workshop/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx index 9c04274b1..79fb42408 100644 --- a/apps/docs/src/components/workshop/DetailPage.tsx +++ b/apps/docs/src/components/workshop/DetailPage.tsx @@ -29,6 +29,7 @@ import { toast } from "sonner"; import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"; import { getWorkshopBackend } from "@site/src/lib/pocketbase"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; import type { SubmissionType } from "@site/src/lib/workshop-schema"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import { @@ -1028,7 +1029,7 @@ export default function MarketplaceDetailPage() { }> {() => ( - + )} diff --git a/apps/docs/src/lib/runtime-config.spec.ts b/apps/docs/src/lib/runtime-config.spec.ts new file mode 100644 index 000000000..90d3a9ed6 --- /dev/null +++ b/apps/docs/src/lib/runtime-config.spec.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { getRuntimeWorkshopApiUrl } from "./runtime-config"; + +describe("Workshop runtime configuration", () => { + afterEach(() => { + delete window.homarrRuntimeConfig; + }); + + test("prefers the URL supplied when the container starts", () => { + window.homarrRuntimeConfig = { workshopApiUrl: "https://v2.preview.homarr.dev" }; + + expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://v2.preview.homarr.dev"); + }); + + test("keeps the build-time URL as a fallback", () => { + expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://homarr.dev"); + }); +}); diff --git a/apps/docs/src/lib/runtime-config.ts b/apps/docs/src/lib/runtime-config.ts new file mode 100644 index 000000000..cf8cb5e3e --- /dev/null +++ b/apps/docs/src/lib/runtime-config.ts @@ -0,0 +1,16 @@ +interface HomarrRuntimeConfig { + homarrWebsiteUrl?: string; + workshopApiUrl?: string; + workshopWebUrl?: string; +} + +declare global { + interface Window { + homarrRuntimeConfig?: Readonly; + } +} + +export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => { + if (typeof window === "undefined") return configuredUrl; + return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin; +}; diff --git a/apps/docs/src/pages/workshop/admin/index.tsx b/apps/docs/src/pages/workshop/admin/index.tsx index a8baf5bcf..10244048a 100644 --- a/apps/docs/src/pages/workshop/admin/index.tsx +++ b/apps/docs/src/pages/workshop/admin/index.tsx @@ -3,12 +3,14 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; + export default function WorkshopAdminRoute() { const { siteConfig } = useDocusaurusContext(); const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? ""; return ( - + ); } diff --git a/apps/docs/src/pages/workshop/index.tsx b/apps/docs/src/pages/workshop/index.tsx index f7fadabf8..e7b8e3a82 100644 --- a/apps/docs/src/pages/workshop/index.tsx +++ b/apps/docs/src/pages/workshop/index.tsx @@ -5,6 +5,7 @@ import Layout from "@theme/Layout"; import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp"; import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; export default function WorkshopPage() { const { siteConfig } = useDocusaurusContext(); @@ -16,7 +17,7 @@ export default function WorkshopPage() { }> {() => ( - + )} diff --git a/apps/docs/static/workshop-runtime-config.js b/apps/docs/static/workshop-runtime-config.js new file mode 100644 index 000000000..6fd1814b0 --- /dev/null +++ b/apps/docs/static/workshop-runtime-config.js @@ -0,0 +1 @@ +globalThis.homarrRuntimeConfig ??= Object.freeze({}); diff --git a/apps/nextjs/src/components/assistant/assistant-message-grouping.ts b/apps/nextjs/src/components/assistant/assistant-message-grouping.ts new file mode 100644 index 000000000..625e00a03 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-message-grouping.ts @@ -0,0 +1,12 @@ +import { groupPartByType } from "@assistant-ui/react"; + +/** + * Keep model reasoning and ordinary tool calls in one trace. Human tools and + * other standalone tool UIs stay outside the trace so approvals and questions + * retain the full conversation width. + */ +export const assistantMessageGroupBy = groupPartByType({ + reasoning: ["group-agent-trace", "group-reasoning"], + "tool-call": ["group-agent-trace", "group-tool"], + "standalone-tool-call": [], +}); diff --git a/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts b/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts index 95ff7e94f..da7896157 100644 --- a/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { + getAssistantContextBreakdown, getAssistantConversationUsage, getAssistantTelemetry, getAssistantUsage, @@ -262,3 +263,53 @@ describe("getAssistantConversationUsage", () => { }); }); }); + +describe("getAssistantContextBreakdown", () => { + test("combines the latest context reading with the latest reported token categories", () => { + const usage = getAssistantConversationUsage([ + createUsageMetadata({ + requestId: "reported", + inputTokens: 12_777, + outputTokens: 55, + cost: 0.00041, + contextUsed: 13_343, + }), + { + custom: { + telemetry: { + requestId: "streaming", + provider: "openrouter", + modelId: "example/model", + startedAt: "2026-08-04T10:00:02.000Z", + steps: [], + }, + }, + }, + ]); + + expect(getAssistantContextBreakdown(usage)).toMatchObject({ + contextUsed: 13_343, + contextLength: 128_000, + remaining: 114_657, + percentage: 10, + inputTokens: 12_777, + outputTokens: 55, + }); + }); + + test("does not report an invalid percentage for an unavailable capacity", () => { + expect( + getAssistantContextBreakdown({ + turns: [], + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + cost: 0, + contextUsed: 10, + contextLength: 0, + }), + ).toEqual({}); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts index 15ce84b6f..b7bb5797c 100644 --- a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts +++ b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts @@ -122,6 +122,17 @@ export type AssistantConversationUsage = { contextLength?: number; }; +export type AssistantContextBreakdown = { + contextUsed?: number; + contextLength?: number; + remaining?: number; + percentage?: number; + inputTokens?: number; + outputTokens?: number; + cachedInputTokens?: number; + reasoningTokens?: number; +}; + const getFiniteNonNegativeNumber = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; @@ -412,6 +423,35 @@ export const getAssistantConversationUsage = (messageMetadata: unknown[]): Assis }; }; +export const getAssistantContextBreakdown = (usage: AssistantConversationUsage): AssistantContextBreakdown => { + const latestTokenTurn = usage.turns.findLast( + (turn) => + turn.inputTokens !== undefined || + turn.outputTokens !== undefined || + turn.cachedInputTokens !== undefined || + turn.reasoningTokens !== undefined, + ); + const { contextUsed, contextLength } = usage; + const hasContext = contextUsed !== undefined && contextLength !== undefined && contextLength > 0; + + return { + ...(hasContext + ? { + contextUsed, + contextLength, + remaining: Math.max(0, contextLength - contextUsed), + percentage: Math.min(100, Math.round((contextUsed / contextLength) * 100)), + } + : {}), + ...(latestTokenTurn?.inputTokens !== undefined ? { inputTokens: latestTokenTurn.inputTokens } : {}), + ...(latestTokenTurn?.outputTokens !== undefined ? { outputTokens: latestTokenTurn.outputTokens } : {}), + ...(latestTokenTurn?.cachedInputTokens !== undefined + ? { cachedInputTokens: latestTokenTurn.cachedInputTokens } + : {}), + ...(latestTokenTurn?.reasoningTokens !== undefined ? { reasoningTokens: latestTokenTurn.reasoningTokens } : {}), + }; +}; + export const resolveAssistantContextWindowTelemetry = ( current: AssistantRequestTelemetry, previousMetadata: unknown[], diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index ac8ea0e7b..a19b4da2b 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -663,6 +663,17 @@ text-align: start; } +.tool[data-compact] { + margin-block: 0; + padding: 0.4rem 0.5rem; + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-body); +} + +.tool[data-compact] .toolHeader > :global(.mantine-Group-root) { + min-width: 0; +} + .approvalPanel { margin-top: 0.8rem; padding-top: 0.8rem; @@ -961,6 +972,10 @@ min-width: 0; } +.agentTraceTools { + min-width: 0; +} + .source { display: inline-flex; align-items: center; @@ -1191,6 +1206,43 @@ background: var(--mantine-color-default); } +.contextBreakdown { + display: grid; + grid-template-columns: 4.75rem minmax(0, 1fr); + align-items: center; + gap: 0.6rem 0.75rem; + padding: 0.55rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-default); +} + +.contextBreakdownRing { + flex: 0 0 auto; +} + +.contextBreakdownWindow { + display: grid; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; +} + +.contextBreakdownWindow > div, +.contextBreakdownCategories > div { + min-width: 0; +} + +.contextBreakdownCategories { + display: grid; + min-width: 0; + grid-column: 1 / -1; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.45rem; + padding-top: 0.5rem; + border-top: 1px solid var(--mantine-color-default-border); +} + .conversationTurn { min-width: 0; padding: 0.5rem 0.55rem; @@ -1626,6 +1678,14 @@ } .conversationUsageSummary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .contextBreakdownWindow { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .contextBreakdownCategories { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 23ff73fbf..4aec558cf 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -27,6 +27,7 @@ import { ActionBarPrimitive, AttachmentPrimitive, BranchPickerPrimitive, + ChainOfThoughtByIndicesProvider, ChainOfThoughtPrimitive, ComposerPrimitive, ErrorPrimitive, @@ -128,14 +129,20 @@ import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./as import type { AssistantDirectiveEntity } from "./assistant-markdown-directives"; import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; -import { getAssistantConversationUsage, getAssistantTelemetry } from "./assistant-message-metadata"; -import type { AssistantConversationTurnUsage } from "./assistant-message-metadata"; +import { + getAssistantContextBreakdown, + getAssistantConversationUsage, + getAssistantTelemetry, +} from "./assistant-message-metadata"; +import type { AssistantContextBreakdown, AssistantConversationTurnUsage } from "./assistant-message-metadata"; +import { assistantMessageGroupBy } from "./assistant-message-grouping"; import type { AssistantPendingAction } from "./assistant-pending-action"; import { AssistantQuestionPortalProvider } from "./assistant-question-portal"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; import { useAssistantReasoningState } from "./assistant-reasoning-state"; import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; import { getToolResultPresentation } from "./assistant-tool-result"; +import { getAssistantToolTraceTarget } from "./assistant-tool-trace"; import { getSafeAssistantHttpUrl } from "./assistant-url"; // Shiki's web grammar bundle is intentionally loaded only when a settled response contains a code @@ -800,6 +807,8 @@ const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: st ); }; +const AgentTraceToolContext = createContext(false); + const ToolPart = ({ toolCallId, toolName, @@ -812,6 +821,7 @@ const ToolPart = ({ timing, }: ToolCallMessagePartProps) => { const t = useScopedI18n("common.assistant"); + const compact = useContext(AgentTraceToolContext); const [opened, setOpened] = useState(false); const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null); const completed = status?.type === "complete"; @@ -823,6 +833,8 @@ const ToolPart = ({ status?.type === "incomplete" || (typeof result === "object" && result !== null && "error" in result)); const successful = completed && !denied && !failed; + const compactPresentation = compact && !awaitingApproval; + const traceTarget = getAssistantToolTraceTarget(args); const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined; const autoApprovalInProgress = useAssistantAutomaticAction({ toolCallId, @@ -835,7 +847,7 @@ const ToolPart = ({ }); return ( - + setOpened((current) => !current)} @@ -851,9 +863,16 @@ const ToolPart = ({ > {denied || failed ? : successful ? : } - - {toolName.replaceAll("_", " ")} - + + + {toolName.replaceAll("_", " ")} + + {compactPresentation && traceTarget && ( + + {traceTarget} + + )} + {duration !== undefined && ( @@ -926,9 +945,14 @@ const ToolPart = ({ )} )} - {successful && result !== undefined && } + {!compactPresentation && successful && result !== undefined && ( + + )} + {compactPresentation && successful && result !== undefined && ( + + )} {t("toolInput")} @@ -953,13 +977,21 @@ const ToolPart = ({ ); }; +const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => ( + + + {children} + + +); + const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => ( - + {children} ); -const AssistantChainOfThought = () => { +const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => { const t = useScopedI18n("common.assistant"); const contentId = useId(); const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext); @@ -988,9 +1020,7 @@ const AssistantChainOfThought = () => { ); @@ -1512,6 +1542,64 @@ const ConversationTurn = ({ ); }; +const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => { + const t = useScopedI18n("common.assistant"); + const hasContext = breakdown.percentage !== undefined; + const categories = [ + { label: t("usage.input"), value: breakdown.inputTokens }, + { label: t("usage.cached"), value: breakdown.cachedInputTokens }, + { label: t("usage.output"), value: breakdown.outputTokens }, + { label: t("usage.reasoning"), value: breakdown.reasoningTokens }, + ]; + + return ( + + + {hasContext ? `${breakdown.percentage}%` : "–"} + + } + /> + + {[ + { label: t("usage.used"), value: breakdown.contextUsed }, + { label: t("usage.remaining"), value: breakdown.remaining }, + { label: t("usage.capacity"), value: breakdown.contextLength }, + ].map((item) => ( + + + {item.label} + + + {item.value?.toLocaleString() ?? t("usage.notReported")} + + + ))} + + + {categories.map((category) => ( + + + {category.label} + + + {category.value?.toLocaleString() ?? t("usage.notReported")} + + + ))} + + + ); +}; + const ConversationContext = () => { const t = useScopedI18n("common.assistant"); const [opened, setOpened] = useState(false); @@ -1529,12 +1617,11 @@ const ConversationContext = () => { [messages], ); const usage = useMemo(() => getAssistantConversationUsage(metadata), [metadata]); + const breakdown = useMemo(() => getAssistantContextBreakdown(usage), [usage]); const hasTokenUsage = usage.turns.some((turn) => turn.totalTokens !== undefined); const hasCost = usage.turns.some((turn) => turn.cost !== undefined); - const hasContext = usage.contextUsed !== undefined && usage.contextLength !== undefined; - const contextPercentage = hasContext - ? Math.min(100, Math.round(((usage.contextUsed ?? 0) / Math.max(usage.contextLength ?? 1, 1)) * 100)) - : 0; + const hasContext = breakdown.percentage !== undefined; + const contextPercentage = breakdown.percentage ?? 0; const quickLabel = [ hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined, hasCost ? formatCost(usage.cost) : undefined, @@ -1607,20 +1694,16 @@ const ConversationContext = () => {
- {t("usage.used")} + {t("usage.agentSteps")} - {usage.contextUsed?.toLocaleString() ?? t("usage.notReported")} + {usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()} - {usage.contextLength !== undefined && ( - - / {usage.contextLength.toLocaleString()} - - )}
+ - + {usage.turns.length === 0 ? ( @@ -1739,17 +1822,44 @@ const WebSearchActivity = () => { const AssistantMessage = () => ( - + {({ part, children }) => { + switch (part.type) { + case "group-agent-trace": { + const startIndex = part.indices[0]; + const endIndex = part.indices.at(-1); + if (startIndex === undefined || endIndex === undefined) return null; + return ( + + {children} + + ); + } + case "group-reasoning": + return <>{children}; + case "group-tool": + return {children}; + case "indicator": + return ; + case "text": + return ; + case "reasoning": + return ; + case "tool-call": + return part.toolUI ?? ; + case "source": + return ; + case "file": + return ; + case "image": + return ; + case "data": + return part.dataRendererUI; + default: + return null; + } }} - /> + diff --git a/apps/nextjs/src/components/assistant/assistant-reasoning-state.spec.tsx b/apps/nextjs/src/components/assistant/assistant-reasoning-state.spec.tsx index 222bc251a..b983b9b63 100644 --- a/apps/nextjs/src/components/assistant/assistant-reasoning-state.spec.tsx +++ b/apps/nextjs/src/components/assistant/assistant-reasoning-state.spec.tsx @@ -1,10 +1,12 @@ // @vitest-environment jsdom +import type { ReactNode } from "react"; import { act, createElement, useEffect } from "react"; import { createRoot } from "react-dom/client"; import type { AssistantClient, ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react"; import { AssistantRuntimeProvider, + ChainOfThoughtByIndicesProvider, ChainOfThoughtPrimitive, MessagePrimitive, ThreadPrimitive, @@ -13,6 +15,7 @@ import { } from "@assistant-ui/react"; import { afterEach, describe, expect, test } from "vitest"; +import { assistantMessageGroupBy } from "./assistant-message-grouping"; import { useAssistantReasoningState } from "./assistant-reasoning-state"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -27,7 +30,13 @@ afterEach(() => { const ReasoningPart = ({ text }: { text: string }) => {text}; const ToolPart = ({ toolName }: { toolName: string }) => {toolName}; -const TestChainOfThought = ({ preferredCollapsed = true }: { preferredCollapsed?: boolean }) => { +const TestChainOfThought = ({ + preferredCollapsed = true, + children, +}: { + preferredCollapsed?: boolean; + children?: ReactNode; +}) => { const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed); return ( @@ -36,14 +45,38 @@ const TestChainOfThought = ({ preferredCollapsed = true }: { preferredCollapsed? data-status={chainStatus.type} data-collapsed={String(collapsed)} > - + {children} ); }; const TestMessage = () => ( - + + {({ part, children }) => { + switch (part.type) { + case "group-agent-trace": { + const startIndex = part.indices[0]; + const endIndex = part.indices.at(-1); + if (startIndex === undefined || endIndex === undefined) return null; + return ( + + {children} + + ); + } + case "group-reasoning": + case "group-tool": + return <>{children}; + case "reasoning": + return ; + case "tool-call": + return ; + default: + return null; + } + }} + ); diff --git a/apps/nextjs/src/components/assistant/assistant-tool-trace.spec.ts b/apps/nextjs/src/components/assistant/assistant-tool-trace.spec.ts new file mode 100644 index 000000000..bbfac2e1e --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-tool-trace.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; + +import { getAssistantToolTraceTarget } from "./assistant-tool-trace"; + +describe("getAssistantToolTraceTarget", () => { + test("uses a human-readable tool argument instead of an opaque id", () => { + expect(getAssistantToolTraceTarget({ id: "app-1", name: " Wikipedia " })).toBe("Wikipedia"); + expect(getAssistantToolTraceTarget({ integrationIds: ["integration-1"], searchText: " discord icon " })).toBe( + "discord icon", + ); + }); + + test("does not expose arbitrary argument values in the collapsed trace", () => { + expect(getAssistantToolTraceTarget({ id: "secret-looking-id", apiKey: "do-not-show" })).toBeNull(); + }); + + test("bounds long labels so the trace remains compact", () => { + expect(getAssistantToolTraceTarget({ title: "a".repeat(100) })).toBe(`${"a".repeat(77)}…`); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-tool-trace.ts b/apps/nextjs/src/components/assistant/assistant-tool-trace.ts new file mode 100644 index 000000000..7ef3795db --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-tool-trace.ts @@ -0,0 +1,16 @@ +const targetKeys = ["name", "title", "query", "searchText", "appName", "widgetName"] as const; + +export const getAssistantToolTraceTarget = (args: unknown): string | null => { + if (!args || typeof args !== "object" || Array.isArray(args)) return null; + const record = args as Record; + + for (const key of targetKeys) { + const value = record[key]; + if (typeof value !== "string") continue; + const normalized = value.trim().replaceAll(/\s+/gu, " "); + if (normalized.length === 0) continue; + return normalized.length > 80 ? `${normalized.slice(0, 77)}…` : normalized; + } + + return null; +}; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index d4d0782ab..ea80787b3 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -1324,7 +1324,7 @@ const configureEditorPlugins = (defaults: Plugins) => dropAnimation: null, keyboardTransition: { duration: 100 }, }); - if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0.16, y: 0.16 } }); + if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0, y: 0.16 } }); return plugin; }); diff --git a/apps/workshop/.env.staging.example b/apps/workshop/.env.staging.example new file mode 100644 index 000000000..c783f53bc --- /dev/null +++ b/apps/workshop/.env.staging.example @@ -0,0 +1,12 @@ +WORKSHOP_IMAGE=ghcr.io/homarr-labs/workshop:v2 +WORKSHOP_EXPOSE_PORT=8090 + +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* + +# Optional GitHub OAuth application for the preview hostname. +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= diff --git a/apps/workshop/Dockerfile b/apps/workshop/Dockerfile index cbae362db..b90e488c3 100644 --- a/apps/workshop/Dockerfile +++ b/apps/workshop/Dockerfile @@ -18,15 +18,17 @@ RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set FROM dependencies AS website ARG HOMARR_WEBSITE_URL=https://homarr.dev ARG WORKSHOP_API_URL=https://homarr.dev +ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL ENV WORKSHOP_API_URL=$WORKSHOP_API_URL +ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime ENV PORT=8090 RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint -RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data +RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data /pb_public ENTRYPOINT ["workshop-entrypoint"] FROM pocketbase-runtime AS development @@ -34,6 +36,12 @@ EXPOSE 8090 HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1 FROM pocketbase-runtime AS production +ARG HOMARR_WEBSITE_URL=https://homarr.dev +ARG WORKSHOP_API_URL=https://homarr.dev +ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop +ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL +ENV WORKSHOP_API_URL=$WORKSHOP_API_URL +ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL COPY --from=website /app/apps/docs/build /pb_public COPY apps/workshop/pb_migrations /pb_migrations COPY apps/workshop/pb_hooks /pb_hooks diff --git a/apps/workshop/README.md b/apps/workshop/README.md index a66208514..4818567c5 100644 --- a/apps/workshop/README.md +++ b/apps/workshop/README.md @@ -70,7 +70,8 @@ pnpm dev `WORKSHOP_API_URL` controls API calls. `WORKSHOP_WEB_URL` controls links from Homarr. `HOMARR_WEBSITE_URL` is the documentation/site base. All three must be absolute HTTP(S) URLs. The old Docusaurus-only `WORKSHOP_URL` variable -remains a deprecated alias for one release. +remains a deprecated alias for one release. The production image writes these values into the browser runtime +configuration at container startup, so changing them requires a restart but not an image rebuild. ## Optional GitHub OAuth @@ -161,6 +162,45 @@ WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev PB_ALLOWED_ORIGINS=* ``` +### Deploy a v2 staging or demo instance + +The staging Compose file runs the published `ghcr.io/homarr-labs/workshop:v2` image, persists `/pb_data`, binds only to +the host loopback interface, and expects TLS to terminate at a reverse proxy. It does not need a repository checkout to +run; copy `docker-compose.staging.yml` and `.env.staging.example` to the deployment host, then: + +```sh +cp .env.staging.example .env.staging +docker compose --env-file .env.staging -f docker-compose.staging.yml pull +docker compose --env-file .env.staging -f docker-compose.staging.yml up --detach +docker compose --env-file .env.staging -f docker-compose.staging.yml ps +curl --fail http://127.0.0.1:8090/api/health +``` + +The checked-in example is ready for `https://v2.preview.homarr.dev`: + +```dotenv +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* +``` + +Proxy the entire `v2.preview.homarr.dev` hostname to `http://127.0.0.1:8090`, including `/api`, `/_/`, and +`/workshop-runtime-config.js`; do not proxy only `/workshop`. The combined image owns both the Docusaurus pages and +PocketBase routes. If the hostname already serves the Homarr application, use a dedicated Workshop hostname instead +and set all four public URL variables to that hostname (with `/workshop` only on `WORKSHOP_WEB_URL`). + +The `v2` image tag is intentionally convenient and mutable for previews. Run `pull` followed by `up --detach` to test a +new v2 image while retaining the named PocketBase volume. Use the immutable `sha-` image tag when promoting a +tested build. The public central Workshop needs `PB_ALLOWED_ORIGINS=*` because Homarr instances on arbitrary origins +call the API and open the OAuth flow; a private demo can instead use a comma-separated allowlist of its browser origins. + +After startup, open `https://v2.preview.homarr.dev/_/` to create the first PocketBase superuser. If GitHub OAuth is +enabled, configure its callback as `https://v2.preview.homarr.dev/api/oauth2-redirect`, set both GitHub credentials, and +restart the service. Verify `/workshop`, `/api/health`, listing requests, sign-in, and one installation from the Homarr +preview before sharing the demo. + Build and smoke-test the combined image locally: ```sh @@ -171,6 +211,10 @@ curl --fail http://127.0.0.1:3003/workshop docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs ``` +`pnpm test:workshop-image` builds the production target, starts it with the preview URL contract, and proves that the +browser runtime configuration, Workshop page, health endpoint, migrations, public listing endpoint, and cross-origin +browser access all work. + Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional, irreversible local reset: diff --git a/apps/workshop/docker-compose.staging.yml b/apps/workshop/docker-compose.staging.yml new file mode 100644 index 000000000..a4604e4c2 --- /dev/null +++ b/apps/workshop/docker-compose.staging.yml @@ -0,0 +1,25 @@ +name: homarr-workshop-staging + +services: + workshop: + image: ${WORKSHOP_IMAGE:-ghcr.io/homarr-labs/workshop:v2} + restart: unless-stopped + environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:?Set HOMARR_WEBSITE_URL in the Workshop environment file} + PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} + PORT: 8090 + WORKSHOP_API_URL: ${WORKSHOP_API_URL:?Set WORKSHOP_API_URL in the Workshop environment file} + WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:?Set WORKSHOP_PUBLIC_ORIGIN in the Workshop environment file} + WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + ports: + - "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090" + volumes: + - pb_data:/pb_data + security_opt: + - no-new-privileges:true + stop_grace_period: 30s + +volumes: + pb_data: diff --git a/apps/workshop/docker-compose.yml b/apps/workshop/docker-compose.yml index c6eac0034..22c1be2e6 100644 --- a/apps/workshop/docker-compose.yml +++ b/apps/workshop/docker-compose.yml @@ -8,8 +8,10 @@ services: target: development restart: unless-stopped environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003} PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} PORT: ${PB_EXPOSE_PORT:-8090} + WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:8090} WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} @@ -33,9 +35,12 @@ services: args: HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003} WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003} + WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003} PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} PORT: ${DOCS_EXPOSE_PORT:-3003} + WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003} WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} diff --git a/apps/workshop/entrypoint.sh b/apps/workshop/entrypoint.sh index 84a04ade4..31d204ed7 100644 --- a/apps/workshop/entrypoint.sh +++ b/apps/workshop/entrypoint.sh @@ -6,6 +6,24 @@ if [ "$(id -u)" -eq 0 ]; then exec su -p -s /bin/sh pocketbase -c 'exec /usr/local/bin/workshop-entrypoint' fi +HOMARR_WEBSITE_URL=${HOMARR_WEBSITE_URL:-https://homarr.dev} +WORKSHOP_API_URL=${WORKSHOP_API_URL:-$HOMARR_WEBSITE_URL} +WORKSHOP_WEB_URL=${WORKSHOP_WEB_URL:-$HOMARR_WEBSITE_URL/workshop} + +encode_runtime_value() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +cat >/pb_public/workshop-runtime-config.js < + new TextDecoder().decode(Uint8Array.from(atob(value), (character) => character.charCodeAt(0))); +globalThis.homarrRuntimeConfig = Object.freeze({ + homarrWebsiteUrl: decodeRuntimeValue("$(encode_runtime_value "$HOMARR_WEBSITE_URL")"), + workshopApiUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_API_URL")"), + workshopWebUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_WEB_URL")") +}); +EOF + exec pocketbase serve \ --http="0.0.0.0:${PORT:-8090}" \ --origins="${PB_ALLOWED_ORIGINS:-*}" \ diff --git a/apps/workshop/test-image.sh b/apps/workshop/test-image.sh new file mode 100644 index 000000000..c56e3b15e --- /dev/null +++ b/apps/workshop/test-image.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +WORKSHOP_IMAGE_TEST_PORT=${WORKSHOP_IMAGE_TEST_PORT:-18091} +WORKSHOP_IMAGE_TEST_NAME="homarr-workshop-image-test-$$" +WORKSHOP_IMAGE_TEST_TAG="homarr-workshop:test-$$" +PREVIEW_ORIGIN=https://v2.preview.homarr.dev + +cleanup() { + docker rm --force "$WORKSHOP_IMAGE_TEST_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +docker build --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" . +docker run --detach --name "$WORKSHOP_IMAGE_TEST_NAME" \ + --publish "127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT:8090" \ + --env HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \ + --env WORKSHOP_API_URL="$PREVIEW_ORIGIN" \ + --env WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ + --env WORKSHOP_PUBLIC_ORIGIN="$PREVIEW_ORIGIN" \ + --env PB_ALLOWED_ORIGINS='*' \ + "$WORKSHOP_IMAGE_TEST_TAG" >/dev/null + +for attempt in $(seq 1 60); do + if curl --fail --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/health" >/dev/null; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs "$WORKSHOP_IMAGE_TEST_NAME" + exit 1 + fi + sleep 1 +done + +WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \ + EXPECTED_HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \ + EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \ + EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ + node apps/workshop/tests/runtime-config.integration.mjs + +curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null +curl --fail --silent \ + "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null +curl --fail --silent --dump-header - --output /dev/null \ + --header 'Origin: https://homarr.example' \ + "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" \ + | tr -d '\r' \ + | grep -qi '^Access-Control-Allow-Origin: \*$' + +echo "Workshop production image passed" diff --git a/apps/workshop/test.sh b/apps/workshop/test.sh index b010a45ca..67a28d080 100644 --- a/apps/workshop/test.sh +++ b/apps/workshop/test.sh @@ -14,7 +14,10 @@ cleanup() { } trap cleanup EXIT -PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ +HOMARR_WEBSITE_URL=https://preview.example.invalid \ + WORKSHOP_API_URL=https://api.preview.example.invalid \ + WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \ + PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ -f apps/workshop/docker-compose.yml up --build -d for attempt in $(seq 1 60); do @@ -29,6 +32,12 @@ for attempt in $(seq 1 60); do sleep 1 done +WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" \ + EXPECTED_HOMARR_WEBSITE_URL=https://preview.example.invalid \ + EXPECTED_WORKSHOP_API_URL=https://api.preview.example.invalid \ + EXPECTED_WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \ + node apps/workshop/tests/runtime-config.integration.mjs + docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \ pocketbase superuser create \ workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data diff --git a/apps/workshop/tests/runtime-config.integration.mjs b/apps/workshop/tests/runtime-config.integration.mjs new file mode 100644 index 000000000..469d5d12c --- /dev/null +++ b/apps/workshop/tests/runtime-config.integration.mjs @@ -0,0 +1,24 @@ +import vm from "node:vm"; + +const baseUrl = process.env.WORKSHOP_TEST_URL; +if (!baseUrl) throw new Error("WORKSHOP_TEST_URL is required"); + +const response = await fetch(`${baseUrl}/workshop-runtime-config.js`); +if (!response.ok) throw new Error(`Runtime configuration returned ${response.status}`); + +const context = { TextDecoder, Uint8Array, atob }; +context.globalThis = context; +vm.runInNewContext(await response.text(), context); + +const actual = JSON.parse(JSON.stringify(context.homarrRuntimeConfig)); +const expected = { + homarrWebsiteUrl: process.env.EXPECTED_HOMARR_WEBSITE_URL, + workshopApiUrl: process.env.EXPECTED_WORKSHOP_API_URL, + workshopWebUrl: process.env.EXPECTED_WORKSHOP_WEB_URL, +}; + +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Unexpected Workshop runtime configuration: ${JSON.stringify(actual)}`); +} + +console.log("Workshop runtime configuration passed"); diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index 7219fe970..ed4d59333 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -54,4 +54,15 @@ for (const required of [ const workflow = await read(".github/workflows/workshop.yml"); if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs"); +const entrypoint = await read("apps/workshop/entrypoint.sh"); +if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) { + throw new Error("Workshop must publish its API URL when the container starts"); +} + +const docsConfig = await read("apps/docs/docusaurus.config.ts"); +if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) { + throw new Error("Workshop must load the container runtime configuration before the documentation bundle"); +} +await access(resolve("apps/docs/static/workshop-runtime-config.js")); + console.log("Workshop static contracts passed"); diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 1be6bdb9b..8a5926205 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -253,6 +253,38 @@ describe("Board grid", () => { expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe( "Left dashboard rail", ); + const railGrid = rail.locator("[data-grid-section-id]").first(); + const railSnapshot = await readEditorGridSnapshotAsync(railGrid); + await rail.evaluate((element) => { + const overflowFixture = document.createElement("div"); + overflowFixture.dataset.e2eRailOverflowFixture = "true"; + overflowFixture.style.position = "absolute"; + overflowFixture.style.insetInlineStart = "100%"; + overflowFixture.style.width = "500px"; + overflowFixture.style.height = "1px"; + overflowFixture.style.pointerEvents = "none"; + element.appendChild(overflowFixture); + }); + await expect + .poll(async () => await rail.evaluate((element) => element.scrollWidth - element.clientWidth)) + .toBeGreaterThan(0); + const railItemBox = await expectBoundingBoxAsync(railItem); + const railDragStart = { + x: railItemBox.x + railItemBox.width / 2, + y: railItemBox.y + railItemBox.height / 2, + }; + await page.mouse.move(railDragStart.x, railDragStart.y); + await page.mouse.down(); + await page.mouse.move(railDragStart.x + 8, railDragStart.y); + await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true"); + await page.mouse.move(railBox.x + railBox.width - 2, railDragStart.y, { steps: 12 }); + await page.waitForTimeout(250); + expect(await rail.evaluate((element) => element.scrollLeft)).toBe(0); + expect((await expectBoundingBoxAsync(rail)).x).toBeCloseTo(railBox.x, 1); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot); + await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove()); await expect(logicalTile).toHaveCSS("overflow-x", "hidden"); await expect(logicalTile).toHaveCSS("overflow-y", "auto"); await logicalTile.evaluate((element) => { @@ -566,9 +598,8 @@ describe("Board grid", () => { }); const containerGrip = containerSection.locator("[data-grid-container-drag-handle]"); await expectTargetSizeAsync(containerGrip); - const railEditor = rail.locator("[data-grid-section-id]").first(); - const railGridBox = await expectBoundingBoxAsync(railEditor); - await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railEditor, 0, 0, canvasScale); + const railGridBox = await expectBoundingBoxAsync(railGrid); + await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railGrid, 0, 0, canvasScale); await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", @@ -677,7 +708,7 @@ describe("Board grid", () => { await railKeyboardEntry.focus(); await railKeyboardEntry.press("Enter"); const mixedInputSnapshot = await readEditorGridSnapshotAsync(mainGrid); - const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railEditor); + const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railGrid); const mixedInputHandleBox = await expectBoundingBoxAsync(eastResizeHandle); await page.mouse.move( mixedInputHandleBox.x + mixedInputHandleBox.width / 2, @@ -690,7 +721,7 @@ describe("Board grid", () => { await page.keyboard.press("Escape"); await page.mouse.up(); await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(mixedInputSnapshot); - await expect.poll(async () => await readEditorGridSnapshotAsync(railEditor)).toEqual(mixedInputRailSnapshot); + await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(mixedInputRailSnapshot); await expectTouchResizeCancellationRestoresAsync( page, mainGrid, diff --git a/package.json b/package.json index c9a60d7e4..15b234120 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ", "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2", "test:workshop": "sh apps/workshop/test.sh", + "test:workshop-image": "sh apps/workshop/test-image.sh", "test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled", "typecheck": "turbo typecheck", "with-env": "dotenv -e .env --"