Merge refreshed v2 stack into release roll-up

This commit is contained in:
Thomas Camlong committed 2026-08-09 22:01:08 +02:00
commit 9ea04aa3c1
32 files changed
+726 -61

No files matched your search

+2 -1
View File
@@ -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')
@@ -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
+11 -7
View File
@@ -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
+39 -2
View File
@@ -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-<commit>` 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.
+3
View File
@@ -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"],
@@ -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() {
<BrowserOnly fallback={<DetailSkeleton />}>
{() => (
<WorkshopErrorBoundary>
<MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />
<MarketplaceDetail workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
+19
View File
@@ -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");
});
});
+16
View File
@@ -0,0 +1,16 @@
interface HomarrRuntimeConfig {
homarrWebsiteUrl?: string;
workshopApiUrl?: string;
workshopWebUrl?: string;
}
declare global {
interface Window {
homarrRuntimeConfig?: Readonly<HomarrRuntimeConfig>;
}
}
export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => {
if (typeof window === "undefined") return configuredUrl;
return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin;
};
+3 -1
View File
@@ -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 (
<Layout title="Workshop moderation" noFooter>
<WorkshopAdmin workshopUrl={workshopUrl} />
<WorkshopAdmin workshopUrl={getRuntimeWorkshopApiUrl(workshopUrl)} />
</Layout>
);
}
+2 -1
View File
@@ -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() {
<BrowserOnly fallback={<WorkshopListingFallback />}>
{() => (
<WorkshopErrorBoundary>
<WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />
<WorkshopApp workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
+1
View File
@@ -0,0 +1 @@
globalThis.homarrRuntimeConfig ??= Object.freeze({});
@@ -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": [],
});
@@ -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({});
});
});
@@ -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[],
@@ -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));
}
@@ -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 (
<Box className={classes.tool}>
<Box className={classes.tool} data-compact={compactPresentation || undefined}>
<UnstyledButton
className={classes.toolHeader}
onClick={() => setOpened((current) => !current)}
@@ -851,9 +863,16 @@ const ToolPart = ({
>
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
</ThemeIcon>
<Text size="sm" fw={600}>
{toolName.replaceAll("_", " ")}
</Text>
<Box miw={0}>
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
{toolName.replaceAll("_", " ")}
</Text>
{compactPresentation && traceTarget && (
<Text size="xs" c="dimmed" lineClamp={1}>
{traceTarget}
</Text>
)}
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{duration !== undefined && (
@@ -926,9 +945,14 @@ const ToolPart = ({
)}
</Box>
)}
{successful && result !== undefined && <ToolResultPreview result={result} toolName={toolName} />}
{!compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<Collapse expanded={opened}>
<Stack gap="xs" mt="sm">
{compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolInput")}
@@ -953,13 +977,21 @@ const ToolPart = ({
);
};
const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => (
<AgentTraceToolContext.Provider value>
<Stack className={classes.agentTraceTools} gap={4}>
{children}
</Stack>
</AgentTraceToolContext.Provider>
);
const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
<Stack className={classes.reasoningParts} gap="xs">
<Stack className={classes.reasoningParts} gap={6}>
{children}
</Stack>
);
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 = () => {
</UnstyledButton>
</ChainOfThoughtPrimitive.AccordionTrigger>
<Box id={contentId} className={classes.reasoningContent} aria-busy={running} hidden={collapsed}>
<ChainOfThoughtPrimitive.Parts
components={{ Reasoning: ReasoningPart, tools: { Fallback: ToolPart }, Layout: ChainOfThoughtLayout }}
/>
<ChainOfThoughtLayout>{children}</ChainOfThoughtLayout>
</Box>
</ChainOfThoughtPrimitive.Root>
);
@@ -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 (
<Box className={classes.contextBreakdown}>
<RingProgress
className={classes.contextBreakdownRing}
size={76}
thickness={7}
roundCaps
sections={
hasContext ? [{ value: breakdown.percentage ?? 0, color: getContextColor(breakdown.percentage ?? 0) }] : []
}
label={
<Text ta="center" size="xs" fw={700}>
{hasContext ? `${breakdown.percentage}%` : "–"}
</Text>
}
/>
<Box className={classes.contextBreakdownWindow}>
{[
{ label: t("usage.used"), value: breakdown.contextUsed },
{ label: t("usage.remaining"), value: breakdown.remaining },
{ label: t("usage.capacity"), value: breakdown.contextLength },
].map((item) => (
<Box key={item.label}>
<Text size="xs" c="dimmed">
{item.label}
</Text>
<Text size="sm" fw={650}>
{item.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
<Box className={classes.contextBreakdownCategories}>
{categories.map((category) => (
<Box key={category.label}>
<Text size="xs" c="dimmed">
{category.label}
</Text>
<Text size="xs" fw={650}>
{category.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
</Box>
);
};
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 = () => {
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.used")}
{t("usage.agentSteps")}
</Text>
<Text size="sm" fw={650}>
{usage.contextUsed?.toLocaleString() ?? t("usage.notReported")}
{usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()}
</Text>
{usage.contextLength !== undefined && (
<Text size="xs" c="dimmed">
/ {usage.contextLength.toLocaleString()}
</Text>
)}
</div>
</Box>
<ConversationContextBreakdown breakdown={breakdown} />
<Divider />
<ScrollArea.Autosize mah="min(21rem, 48dvh)" type="auto" offsetScrollbars>
<ScrollArea.Autosize mah="min(14rem, 30dvh)" type="auto" offsetScrollbars>
<Stack gap={5}>
{usage.turns.length === 0 ? (
<Text size="sm" c="dimmed" py="xs">
@@ -1739,17 +1822,44 @@ const WebSearchActivity = () => {
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
unstable_showEmptyOnNonTextEnd={false}
components={{
Empty: AssistantMessagePending,
Text: AssistantTextPart,
Source: SourcePart,
File: FilePart,
Image: ImagePart,
ChainOfThought: AssistantChainOfThought,
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
{({ 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 (
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
<AssistantChainOfThought>{children}</AssistantChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
}
case "group-reasoning":
return <>{children}</>;
case "group-tool":
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
case "indicator":
return <AssistantMessagePending status={{ type: "running" }} />;
case "text":
return <AssistantTextPart />;
case "reasoning":
return <ReasoningPart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "data":
return part.dataRendererUI;
default:
return null;
}
}}
/>
</MessagePrimitive.GroupedParts>
<RuntimeError />
<WebSearchActivity />
<AssistantMessageActions />
@@ -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 }) => <span data-testid="reasoning-part">{text}</span>;
const ToolPart = ({ toolName }: { toolName: string }) => <span data-testid="tool-part">{toolName}</span>;
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)}
>
<ChainOfThoughtPrimitive.Parts components={{ Reasoning: ReasoningPart, tools: { Fallback: ToolPart } }} />
{children}
</ChainOfThoughtPrimitive.Root>
);
};
const TestMessage = () => (
<MessagePrimitive.Root>
<MessagePrimitive.Parts components={{ ChainOfThought: TestChainOfThought }} />
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="never">
{({ 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 (
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
<TestChainOfThought>{children}</TestChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
}
case "group-reasoning":
case "group-tool":
return <>{children}</>;
case "reasoning":
return <ReasoningPart text={part.text} />;
case "tool-call":
return <ToolPart toolName={part.toolName} />;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
</MessagePrimitive.Root>
);
@@ -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)}…`);
});
});
@@ -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<string, unknown>;
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;
};
@@ -1324,7 +1324,7 @@ const configureEditorPlugins = (defaults: Plugins<DragDropManager>) =>
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;
});
+12
View File
@@ -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=
+9 -1
View File
@@ -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
+45 -1
View File
@@ -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-<commit>` 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:
+25
View File
@@ -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:
+5
View File
@@ -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:-}
+18
View File
@@ -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 <<EOF
const decodeRuntimeValue = (value) =>
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:-*}" \
+50
View File
@@ -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"
+10 -1
View File
@@ -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
@@ -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");
@@ -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");
+36 -5
View File
@@ -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,
+1
View File
@@ -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 --"