Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@effect/atom-react": "catalog:",
"@formkit/auto-animate": "^0.9.0",
"@kitlangton/rolling-number": "^0.4.1",
"@legendapp/list": "catalog:",
"@noble/hashes": "catalog:",
"@pierre/diffs": "catalog:",
Expand Down Expand Up @@ -64,6 +65,7 @@
"lucide-react": "^0.564.0",
"mermaid": "^11.17.2",
"morphicons": "^1.7.1",
"motion": "^13.4.4",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
Expand Down
53 changes: 35 additions & 18 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
import { AnimatedNumber, AnimatedText, AnimatedIcon } from "./ui/changing-state";
import { useAtomValue } from "@effect/atom-react";
import {
AuthOrchestrationOperateScope,
Expand Down Expand Up @@ -320,18 +321,30 @@ function GitQuickActionIcon({
className?: string;
SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
}) {
if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />;
if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;
if (quickAction.kind === "run_action") {
if (quickAction.action === "commit") return <GitCommitIcon className={className} />;
if (quickAction.action === "push" || quickAction.action === "commit_push") {
return <CloudUploadIcon className={className} />;
const iconKind = (() => {
if (quickAction.kind === "open_publish") return "upload";
if (quickAction.kind === "run_pull") return "download";
if (quickAction.kind === "run_action") {
if (quickAction.action === "commit") return "commit";
if (quickAction.action === "push" || quickAction.action === "commit_push") return "upload";
return "source";
}
return <SourceControlIcon className={className} />;
}
if (quickAction.label === "Commit") return <GitCommitIcon className={className} />;
if (quickAction.label === "Push") return <CloudUploadIcon className={className} />;
return <InfoIcon className={className} />;
if (quickAction.label === "Commit") return "commit";
if (quickAction.label === "Push") return "upload";
return "info";
})();
const Icon = {
source: SourceControlIcon,
upload: CloudUploadIcon,
download: CloudDownloadIcon,
commit: GitCommitIcon,
info: InfoIcon,
}[iconKind];
return (
<AnimatedIcon stateKey={iconKind} className={className}>
<Icon className={className} />
</AnimatedIcon>
);
}

function GitActionElapsedTime({
Expand Down Expand Up @@ -1708,7 +1721,7 @@ export default function GitActionsControl({
>
<GitBranchPlusIcon className="size-4" />
<MenuItemLabel>
{initAction.isPending ? "Initializing..." : "Initialize Git"}
<AnimatedText value={initAction.isPending ? "Initializing..." : "Initialize Git"} />
</MenuItemLabel>
</MenuItem>
) : (
Expand All @@ -1724,7 +1737,9 @@ export default function GitActionsControl({
quickAction={quickAction}
SourceControlIcon={SourceControlIcon}
/>
<MenuItemLabel>{quickAction.label}</MenuItemLabel>
<MenuItemLabel>
<AnimatedText value={quickAction.label} />
</MenuItemLabel>
</MenuItem>
{quickActionDisabledReason && (
<p className="max-w-64 px-2 py-1.5 text-xs text-warning">
Expand Down Expand Up @@ -1755,7 +1770,7 @@ export default function GitActionsControl({
>
<GitBranchPlusIcon className="size-3.5" aria-hidden />
<span className={cn("ml-0.5", isPanel && THREAD_DETAILS_PANEL_LABEL_CLASS)}>
{initAction.isPending ? "Initializing..." : "Initialize Git"}
<AnimatedText value={initAction.isPending ? "Initializing..." : "Initialize Git"} />
</span>
</ThreadDetailsControl>
) : compact && !gitActionProgress && !visibleInlineSuccess ? null : (
Expand Down Expand Up @@ -1815,7 +1830,7 @@ export default function GitActionsControl({
isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
)}
>
{quickAction.label}
<AnimatedText value={quickAction.label} />
</span>
</PopoverTrigger>
<PopoverPopup tooltipStyle side="bottom" align="start">
Expand All @@ -1842,7 +1857,7 @@ export default function GitActionsControl({
isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
)}
>
{quickAction.label}
<AnimatedText value={quickAction.label} />
</span>
</ThreadDetailsControl>
)}
Expand Down Expand Up @@ -1911,9 +1926,11 @@ export default function GitActionsControl({
<FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden />
<span className={cn("flex-1 text-left", THREAD_DETAILS_PANEL_LABEL_CLASS)}>Changes</span>
<span className="flex items-center gap-1 font-mono text-2xs tabular-nums">
<span className="text-success">+{formatDiffCount(changesTotals?.insertions ?? 0)}</span>
<span className="text-success">
+<AnimatedNumber value={changesTotals?.insertions ?? 0} />
</span>
<span className="text-destructive">
-{formatDiffCount(changesTotals?.deletions ?? 0)}
-<AnimatedNumber value={changesTotals?.deletions ?? 0} />
</span>
</span>
</ThreadDetailsControl>
Expand Down
66 changes: 46 additions & 20 deletions apps/web/src/components/ProjectScriptsControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } }));
vi.mock("~/state/session", () => ({ readEnvironmentScope: () => true }));

vi.mock("../uiAnimations", () => ({
useUiAnimationSettings: () => ({ active: true, durationMs: 180 }),
}));

vi.mock("./projectScriptEditor", () => ({
EMPTY_PROJECT_SCRIPT_INPUT: {
name: "",
Expand Down Expand Up @@ -125,28 +129,32 @@ const scripts = [

const action = async () => AsyncResult.success(undefined);

function renderControl(
input: {
startingScriptIds?: ReadonlySet<string>;
runningScriptIds?: ReadonlySet<string>;
} = {},
) {
interface ControlState {
startingScriptIds?: ReadonlySet<string>;
runningScriptIds?: ReadonlySet<string>;
}

function Control(input: ControlState) {
return (
<ProjectScriptsControl
scripts={scripts}
environmentId={EnvironmentId.make("environment-1")}
{...(input.startingScriptIds ? { startingScriptIds: input.startingScriptIds } : {})}
{...(input.runningScriptIds ? { runningScriptIds: input.runningScriptIds } : {})}
onRunScript={vi.fn()}
onStopScript={vi.fn()}
onAddScript={action}
onUpdateScript={action}
onDeleteScript={action}
supportsWorktreeRuns
/>
);
}

function renderControl(input: ControlState = {}) {
let nextRenderer!: ReactTestRenderer;
act(() => {
nextRenderer = create(
<ProjectScriptsControl
scripts={scripts}
environmentId={EnvironmentId.make("environment-1")}
{...(input.startingScriptIds ? { startingScriptIds: input.startingScriptIds } : {})}
{...(input.runningScriptIds ? { runningScriptIds: input.runningScriptIds } : {})}
onRunScript={vi.fn()}
onStopScript={vi.fn()}
onAddScript={action}
onUpdateScript={action}
onDeleteScript={action}
supportsWorktreeRuns
/>,
);
nextRenderer = create(<Control {...input} />);
});
return nextRenderer;
}
Expand Down Expand Up @@ -203,4 +211,22 @@ describe("ProjectScriptsControl", () => {
expect(renderer.root.findAllByProps({ "data-icon": "square" })).not.toHaveLength(0);
expect(runButton.props["aria-busy"]).toBe(false);
});

it("animates the primary icon only when its command state changes", () => {
renderer = renderControl();
expect(renderer.root.findByProps({ "data-changing-state": "build:idle" })).toBeDefined();

act(() => {
renderer?.update(<Control startingScriptIds={new Set(["build"])} />);
});

expect(renderer.root.findAllByProps({ "data-changing-state": "build:idle" })).toHaveLength(0);
expect(renderer.root.findByProps({ "data-changing-state": "build:starting" })).toBeDefined();
expect(
renderer.root
.findByProps({ "data-changing-state": "build:starting" })
.findAllByType("span")
.filter((node) => node.props["data-icon"] === "loader"),
).toHaveLength(1);
});
});
28 changes: 20 additions & 8 deletions apps/web/src/components/ProjectScriptsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
} from "./chat/threadDetailsPanelStyles";
import { AnimatedText, AnimatedIcon, useAnimatedLayout } from "./ui/changing-state";

export type { NewProjectScriptInput, ProjectScriptActionResult };

Expand Down Expand Up @@ -224,6 +225,12 @@ export default function ProjectScriptsControl({
: primaryScriptIsStarting
? `Starting ${primaryScript.name}`
: `Run ${primaryScript.name}`;
const primaryScriptState = primaryScriptIsRunning
? "running"
: primaryScriptIsStarting
? "starting"
: "idle";
const animatedGroupRef = useAnimatedLayout<HTMLDivElement>();

const scriptItems = (
<>
Expand Down Expand Up @@ -346,7 +353,7 @@ export default function ProjectScriptsControl({
aria-label="Project scripts"
{...(isPanel
? { className: THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, ref: panelAnchorRef }
: {})}
: { ref: animatedGroupRef })}
>
<Tooltip>
<TooltipTrigger
Expand All @@ -368,19 +375,24 @@ export default function ProjectScriptsControl({
/>
}
>
<ScriptStateIcon
icon={primaryScript.icon}
running={primaryScriptIsRunning}
starting={primaryScriptIsStarting}
{...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})}
/>
<AnimatedIcon
stateKey={`${primaryScript.id}:${primaryScriptState}`}
className="shrink-0"
>
<ScriptStateIcon
icon={primaryScript.icon}
running={primaryScriptIsRunning}
starting={primaryScriptIsStarting}
{...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})}
/>
</AnimatedIcon>
<span
className={cn(
"sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
)}
>
{primaryScript.name}
<AnimatedText value={primaryScript.name} />
</span>
</TooltipTrigger>
<TooltipPopup side="top">
Expand Down
Loading
Loading