Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a16ba18
feat: expose workspace initialization steps and progress
ibetitsmike Sep 11, 2026
1e5a3d8
feat: stream local worktree checkout progress
ibetitsmike Sep 11, 2026
c900320
feat(workspace): show inline creation progress card
ibetitsmike Sep 11, 2026
5eac8a4
fix(worktree): disable git's progress delay for checkout
ibetitsmike Sep 11, 2026
4348f56
refactor: polish workspace creation card
ibetitsmike Sep 11, 2026
02ec2c2
fix: address Codex review on the creation card
ibetitsmike Sep 11, 2026
2267420
fix(worktree): keep worktree-add semantics for the streamed checkout
ibetitsmike Sep 11, 2026
c68f1dd
fix: classify git chatter as output and scroll revealed logs
ibetitsmike Sep 11, 2026
8704b61
tests: let the creation card sit between user-0 and the truncation seam
ibetitsmike Sep 11, 2026
5146dc9
feat(worktree): defer local checkout into the init phase
ibetitsmike Sep 11, 2026
92baaf9
fix(worktree): classify checkout diagnostics once by exit status
ibetitsmike Sep 11, 2026
0cda234
fix(worktree): keep the branch reserved and sanitize on every materia…
ibetitsmike Sep 11, 2026
a422dae
fix(worktree): never clobber or strand a deferred checkout
ibetitsmike Sep 11, 2026
9d65557
fix(worktree): finish the checkout for archive, rebuild the index on …
ibetitsmike Sep 11, 2026
d28e33a
fix(worktree): reserve the branch through the streamed checkout, let …
ibetitsmike Sep 11, 2026
e501fbb
fix(worktree): abortable xumignore sync, no double claim after a lost…
ibetitsmike Sep 11, 2026
0668eb8
Keep the restart blocker until the init status write lands
ibetitsmike Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions docs/hooks/init.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,9 @@ bun install

## Output

Init output appears in a banner at the top of the workspace. Click to expand/collapse the log. The banner shows:
The creation card appears after the first user message in the transcript. While setup runs, it shows a checklist of steps and checkout progress when available. Select **More details** to see the project path and stdout/stderr output.

- Script path (`.xum/init`)
- Status (running, success, or exit code on failure)
- Full stdout/stderr output
On success, the card collapses to **Workspace created** with the elapsed time. Click the header to expand the log. On failure, the card stays expanded and shows the exit code and error output.

## Idempotency

Expand Down
22 changes: 22 additions & 0 deletions src/browser/components/ProgressBar/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { cn } from "@/common/lib/utils";

interface ProgressBarProps {
value: number;
className?: string;
"aria-label"?: string;
}

export function ProgressBar(props: ProgressBarProps) {
return (
<div
role="progressbar"
aria-valuenow={props.value}
aria-valuemin={0}
aria-valuemax={100}
aria-label={props["aria-label"]}
className={cn("bg-init-output-bg h-1.5 overflow-hidden rounded-full", props.className)}
>
<div className="bg-accent h-full rounded-full" style={{ width: `${props.value}%` }} />
</div>
);
}
184 changes: 135 additions & 49 deletions src/browser/features/Messages/InitMessage.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "@storybook/test";
import { InitMessage } from "@/browser/features/Messages/InitMessage";
import { STABLE_TIMESTAMP } from "@/browser/stories/mocks/workspaces";
import { lightweightMeta } from "@/browser/stories/meta.js";
import type { DisplayedMessage } from "@/common/types/message";

type WorkspaceInitMessage = Extract<DisplayedMessage, { type: "workspace-init" }>;

const INIT_SUCCESS_MESSAGE: WorkspaceInitMessage = {
const RUNNING_MESSAGE: WorkspaceInitMessage = {
type: "workspace-init",
id: "init-success",
historySequence: 1,
id: "workspace-init",
historySequence: -1,
status: "running",
hookPath: "/home/user/projects/my-app",
lines: [
{ line: "Preparing workspace", isError: false, step: true },
{ line: "Creating git worktree", isError: false, step: true },
{ line: "Preparing worktree (new branch 'feature')", isError: false },
{ line: "Checking out files", isError: false, step: true },
{ line: "HEAD is now at 1234567 Add application", isError: false },
],
progress: { label: "Updating files", percent: 87 },
exitCode: null,
timestamp: STABLE_TIMESTAMP,
durationMs: null,
};

const SUCCESS_MESSAGE: WorkspaceInitMessage = {
...RUNNING_MESSAGE,
status: "success",
hookPath: "/home/user/projects/my-app/.mux/init.sh",
lines: [
{ line: "Installing dependencies...", isError: false },
{ line: "Setting up environment variables...", isError: false },
{ line: "Starting development server...", isError: false },
...RUNNING_MESSAGE.lines,
{ line: "Running init hook: .xum/init", isError: false, step: true },
{ line: "Dependencies installed", isError: false },
],
progress: null,
exitCode: 0,
timestamp: STABLE_TIMESTAMP - 106000,
durationMs: 3000,
};

const INIT_ERROR_MESSAGE: WorkspaceInitMessage = {
type: "workspace-init",
id: "init-error",
historySequence: 1,
const ERROR_MESSAGE: WorkspaceInitMessage = {
...SUCCESS_MESSAGE,
status: "error",
hookPath: "/home/user/projects/my-app/.mux/init.sh",
lines: [
{ line: "Installing dependencies...", isError: false },
{ line: "Failed to install package 'missing-dep'", isError: true },
{ line: "npm ERR! code E404", isError: true },
],
lines: [...SUCCESS_MESSAGE.lines, { line: "Package installation failed", isError: true }],
exitCode: 1,
timestamp: STABLE_TIMESTAMP - 107000,
durationMs: 3000,
};

const meta = {
Expand All @@ -44,51 +52,129 @@ const meta = {
component: InitMessage,
render: (args) => (
<div className="bg-background flex min-h-screen items-start p-6">
<div className="w-full max-w-2xl">
<div className="w-full max-w-2xl min-w-0">
<InitMessage {...args} />
</div>
</div>
),
} satisfies Meta<typeof InitMessage>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* Story showing the InitMessage component in success state.
* Tests the workspace init hook display with completed status.
*/
export const InitHookSuccess: Story = {
args: {
message: INIT_SUCCESS_MESSAGE,
export const Running: Story = {
args: { message: RUNNING_MESSAGE },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "87");
await expect(canvas.getAllByLabelText("Completed")).toHaveLength(2);
await expect(canvas.getByLabelText("In progress")).toBeVisible();
const details = canvas.getByRole("button", { name: "More details" });
await expect(details).toHaveAttribute("aria-expanded", "false");
await expect(canvas.queryByText(RUNNING_MESSAGE.hookPath)).not.toBeInTheDocument();
await userEvent.click(details);
await expect(canvas.getByText(RUNNING_MESSAGE.hookPath)).toBeVisible();
await expect(canvas.getByText(RUNNING_MESSAGE.lines[2].line)).toBeVisible();
await userEvent.click(details);
},
parameters: {
docs: {
description: {
story:
"Shows the InitMessage component after a successful init hook execution. " +
"The message displays with a green checkmark, hook path, and output lines.",
},
},
};

export const RunningWithoutProgress: Story = {
args: { message: { ...RUNNING_MESSAGE, progress: null } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("progressbar")).not.toBeInTheDocument();
await expect(canvas.getByLabelText("In progress")).toBeVisible();
},
};

export const InitHookSuccess: Story = {
args: { message: SUCCESS_MESSAGE },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const header = canvas.getByRole("button", { name: /Workspace created/ });
await expect(header).toHaveAttribute("aria-expanded", "false");
await expect(canvas.queryByRole("list")).not.toBeInTheDocument();
await userEvent.click(header);
await expect(canvas.getByText("Dependencies installed")).toBeVisible();
await expect(canvas.getAllByLabelText("Completed")).toHaveLength(4);
await userEvent.click(header);
await expect(canvas.queryByText("Dependencies installed")).not.toBeInTheDocument();
},
};

/**
* Story showing the InitMessage component in error state.
* Tests the workspace init hook display with failed status.
*/
export const InitHookError: Story = {
args: { message: ERROR_MESSAGE },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: /Workspace setup failed/ })).toHaveAttribute(
"aria-expanded",
"true"
);
await expect(canvas.getByRole("button", { name: "More details" })).toHaveAttribute(
"aria-expanded",
"true"
);
await expect(canvas.getByText("Package installation failed")).toHaveClass(
"text-init-output-error-text"
);
await expect(canvas.getByLabelText("Failed")).toBeVisible();
},
};

export const LegacySuccess: Story = {
args: {
message: INIT_ERROR_MESSAGE,
message: {
...SUCCESS_MESSAGE,
lines: SUCCESS_MESSAGE.lines.map(({ line, isError }) => ({ line, isError })),
},
},
parameters: {
docs: {
description: {
story:
"Shows the InitMessage component after a failed init hook execution. " +
"The message displays with a red alert icon, error styling, and error output.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /Workspace created/ }));
await expect(canvas.getByText("Dependencies installed")).toBeVisible();
await expect(canvas.queryByRole("button", { name: "More details" })).not.toBeInTheDocument();
await expect(canvas.queryByRole("list")).not.toBeInTheDocument();
},
};

export const RunningPhone: Story = {
...Running,
args: {
message: {
...RUNNING_MESSAGE,
lines: [
...RUNNING_MESSAGE.lines,
{
line: "Checking out files for a workspace with a very long descriptive branch name",
isError: false,
step: true,
},
],
},
},
decorators: [
(Story) => (
<div data-testid="init-phone" style={{ width: 375, maxWidth: "100%" }}>
<Story />
</div>
),
],
globals: { viewport: { value: "mobile1", isRotated: false } },
parameters: { pixel: { matrix: { viewports: ["phone"] } } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const frame = canvas.getByTestId("init-phone");
const progress = canvas.getByRole("progressbar");
await expect(frame.getBoundingClientRect().width).toBeLessThanOrEqual(375);
await expect(frame.scrollWidth).toBeLessThanOrEqual(frame.clientWidth);
await expect(progress.getBoundingClientRect().width).toBeGreaterThan(0);
await expect(progress.getBoundingClientRect().right).toBeLessThanOrEqual(
frame.getBoundingClientRect().right
);
const percent = canvas.getByText("87%");
await expect(percent.getBoundingClientRect().right).toBeLessThanOrEqual(
frame.getBoundingClientRect().right
);
},
};
Loading
Loading