bc218dfbbe
* feat: add workspace sources from files panel * fix: preserve worktree identity during recovery * feat: unify workspace source actions * fix: validate recovered workspace repositories * fix: harden workspace source filesystem boundaries * test: split workspace repository chip coverage * fix: restrict repository rescan recovery paths * fix: stabilize workspace source CI checks * test: canonicalize workspace source paths * test: align workspace source container paths * fix: populate direct workspace repository names * test: preserve git fixture rewrites across restarts * test: isolate backend git fixture config * fix: resume remote workspace executions before creation * fix: preserve ssh agent identity during resume * fix: preserve SSH workspace across backend restart * fix: simplify workspace actions trigger * fix: refine add workspace repositories UX * docs: narrow workspace source UX updates * fix: avoid phantom turn on session resume * test(web): normalize workspace source request headers * feat(web): streamline workspace source picker * docs: remove trailing spaces from workspace source plan * fix: prevent resume status from creating phantom turns * fix(web): stabilize workspace source attachment * refactor(web): extract workspace source store contract * fix: avoid phantom turns on idle agent resume * fix: address backend static analysis findings * fix: preserve agentctl readiness status value * fix: preserve resume messages without active turns * fix: resume agents from promoted workspace root * chore: merge origin/main into feature/add-new-repo-folder-d97 * feat(web): explain workspace source consequences * test(websocket): count session activity subscription
150 lines
4.0 KiB
TypeScript
150 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type SourceMode = "workspace" | "remote" | "scratch";
|
|
|
|
function resolveMode(useRemote: boolean, noRepository: boolean): SourceMode {
|
|
if (noRepository) return "scratch";
|
|
if (useRemote) return "remote";
|
|
return "workspace";
|
|
}
|
|
|
|
type SourceModeSwitchProps = {
|
|
useRemote: boolean;
|
|
noRepository: boolean;
|
|
onToggleRemote?: () => void;
|
|
onToggleNoRepository?: () => void;
|
|
};
|
|
|
|
type ControlledSourceModeOption<T extends string> = {
|
|
value: T;
|
|
label: string;
|
|
testId: string;
|
|
legacyTestId?: string;
|
|
};
|
|
|
|
type ControlledSourceModeSwitchProps<T extends string> = {
|
|
mode: T;
|
|
options: readonly ControlledSourceModeOption<T>[];
|
|
onModeChange: (mode: T) => void;
|
|
touchSized?: boolean;
|
|
};
|
|
|
|
export function ControlledSourceModeSwitch<T extends string>({
|
|
mode,
|
|
options,
|
|
onModeChange,
|
|
touchSized = false,
|
|
}: ControlledSourceModeSwitchProps<T>) {
|
|
return (
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="Source"
|
|
className="inline-flex items-center rounded-md border border-border/60 bg-muted/20 p-0.5"
|
|
>
|
|
{options.map((option) => {
|
|
const isActive = mode === option.value;
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={isActive}
|
|
data-testid={option.testId}
|
|
data-legacy-testid={option.legacyTestId}
|
|
onClick={() => onModeChange(option.value)}
|
|
className={cn(
|
|
"rounded-sm px-2 text-[11px] font-medium transition-colors cursor-pointer",
|
|
touchSized ? "min-h-11" : "py-0.5",
|
|
isActive
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Three-mode segmented control rendered at the right edge of the chip row:
|
|
* - Repo : pick a workspace repository (default)
|
|
* - Remote : paste a GitHub URL (Task 4 renamed "url" → "remote")
|
|
* - None : run in a scratch workspace or a folder you pick
|
|
*
|
|
* Switching modes calls the underlying single-mode togglers in the right
|
|
* order so we never end up in two modes at once. Hidden when no togglers
|
|
* are provided (e.g. locked-fields wrapper).
|
|
*/
|
|
export function SourceModeSwitch({
|
|
useRemote,
|
|
noRepository,
|
|
onToggleRemote,
|
|
onToggleNoRepository,
|
|
}: SourceModeSwitchProps) {
|
|
if (!onToggleRemote && !onToggleNoRepository) return null;
|
|
const mode = resolveMode(useRemote, noRepository);
|
|
const setMode = (next: SourceMode) =>
|
|
switchMode({
|
|
from: mode,
|
|
to: next,
|
|
onToggleRemote,
|
|
onToggleNoRepository,
|
|
});
|
|
return (
|
|
<div className="ml-auto">
|
|
<ControlledSourceModeSwitch
|
|
mode={mode}
|
|
onModeChange={setMode}
|
|
options={[
|
|
{ value: "workspace", label: "Repo", testId: "source-mode-workspace" },
|
|
...(onToggleRemote
|
|
? [
|
|
{
|
|
value: "remote" as const,
|
|
label: "Remote",
|
|
testId: "source-mode-remote",
|
|
legacyTestId: "toggle-github-url",
|
|
},
|
|
]
|
|
: []),
|
|
...(onToggleNoRepository
|
|
? [{ value: "scratch" as const, label: "None", testId: "source-mode-scratch" }]
|
|
: []),
|
|
]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function switchMode({
|
|
from,
|
|
to,
|
|
onToggleRemote,
|
|
onToggleNoRepository,
|
|
}: {
|
|
from: SourceMode;
|
|
to: SourceMode;
|
|
onToggleRemote?: () => void;
|
|
onToggleNoRepository?: () => void;
|
|
}) {
|
|
if (from === to) return;
|
|
if (to === "remote") {
|
|
if (from === "scratch") onToggleNoRepository?.();
|
|
onToggleRemote?.();
|
|
return;
|
|
}
|
|
if (to === "scratch") {
|
|
if (from === "remote") onToggleRemote?.();
|
|
onToggleNoRepository?.();
|
|
return;
|
|
}
|
|
// workspace
|
|
if (from === "remote") onToggleRemote?.();
|
|
else if (from === "scratch") onToggleNoRepository?.();
|
|
}
|