paperclip/ui/src/components/RoutineRunVariablesDialog.tsx

480 lines
19 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useMemo, useState } from "react";
import type { Agent, IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
function buildInitialValues(variables: RoutineVariable[]) {
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
}
function buildInitialRunSelection(input: {
defaultAssigneeAgentId?: string | null;
defaultProjectId?: string | null;
}) {
return {
assigneeAgentId: input.defaultAssigneeAgentId ?? "",
projectId: input.defaultProjectId ?? "",
};
}
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? null;
}
function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (
defaultMode === "isolated_workspace" ||
defaultMode === "operator_branch" ||
defaultMode === "adapter_default"
) {
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
}
return "shared_workspace";
}
function buildInitialWorkspaceConfig(project: Project | null | undefined) {
const defaultMode = defaultExecutionWorkspaceModeForProject(project);
return {
executionWorkspaceId: null as string | null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: { mode: defaultMode } as IssueExecutionWorkspaceSettings,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(project),
};
}
function workspaceConfigEquals(
a: ReturnType<typeof buildInitialWorkspaceConfig>,
b: ReturnType<typeof buildInitialWorkspaceConfig>,
) {
return a.executionWorkspaceId === b.executionWorkspaceId
&& a.executionWorkspacePreference === b.executionWorkspacePreference
&& a.projectWorkspaceId === b.projectWorkspaceId
&& JSON.stringify(a.executionWorkspaceSettings ?? null) === JSON.stringify(b.executionWorkspaceSettings ?? null);
}
function applyWorkspaceDraft(
current: ReturnType<typeof buildInitialWorkspaceConfig>,
data: Record<string, unknown>,
) {
const next = {
...current,
executionWorkspaceId: (data.executionWorkspaceId as string | null | undefined) ?? null,
executionWorkspacePreference:
(data.executionWorkspacePreference as string | null | undefined)
?? current.executionWorkspacePreference,
executionWorkspaceSettings:
(data.executionWorkspaceSettings as IssueExecutionWorkspaceSettings | null | undefined)
?? current.executionWorkspaceSettings,
};
return workspaceConfigEquals(current, next) ? current : next;
}
function isMissingRequiredValue(value: unknown) {
return value == null || (typeof value === "string" && value.trim().length === 0);
}
function supportsRoutineRunWorkspaceSelection(
project: Project | null | undefined,
isolatedWorkspacesEnabled: boolean,
) {
return isolatedWorkspacesEnabled && Boolean(project?.executionWorkspacePolicy?.enabled);
}
export function routineRunNeedsConfiguration(input: {
variables: RoutineVariable[];
project: Project | null | undefined;
isolatedWorkspacesEnabled: boolean;
}) {
return input.variables.length > 0
|| supportsRoutineRunWorkspaceSelection(input.project, input.isolatedWorkspacesEnabled);
}
export interface RoutineRunDialogSubmitData {
variables?: Record<string, string | number | boolean>;
assigneeAgentId?: string | null;
projectId?: string | null;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
}
export function RoutineRunVariablesDialog({
open,
onOpenChange,
companyId,
[codex] improve issue and routine UI responsiveness (#3744) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators rely on issue, inbox, and routine views to understand what the company is doing in real time > - Those views need to stay fast and readable even when issue lists, markdown comments, and run metadata get large > - The current branch had a coherent set of UI and live-update improvements spread across issue search, issue detail rendering, routine affordances, and workspace lookups > - This pull request groups those board-facing changes into one standalone branch that can merge independently of the heartbeat/runtime work > - The benefit is a faster, clearer issue and routine workflow without changing the underlying task model ## What Changed - Show routine execution issues by default and rename the filter to `Hide routine runs` so the default state no longer looks like an active filter. - Show the routine name in the run dialog and tighten the issue properties pane with a workspace link, copy-on-click behavior, and an inline parent arrow. - Reduce issue detail rerenders, keep queued issue chat mounted, improve issues page search responsiveness, and speed up issues first paint. - Add inbox "other search results", refresh visible issue runs after status updates, and optimize workspace lookups through summary-mode execution workspace queries. - Improve markdown wrapping and scrolling behavior for long strings and self-comment code blocks. - Relax the markdown sanitizer assertion so the test still validates safety after the new wrap-friendly inline styles. ## Verification - `pnpm vitest run ui/src/components/IssuesList.test.tsx ui/src/lib/inbox.test.ts ui/src/pages/Issues.test.tsx ui/src/context/BreadcrumbContext.test.tsx ui/src/context/LiveUpdatesProvider.test.ts ui/src/components/MarkdownBody.test.tsx ui/src/api/execution-workspaces.test.ts server/src/__tests__/execution-workspaces-routes.test.ts` ## Risks - This touches several issue-facing UI surfaces at once, so regressions would most likely show up as stale rendering, search result mismatches, or small markdown presentation differences. - The workspace lookup optimization depends on the summary-mode route shape staying aligned between server and UI. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 15:54:05 -05:00
routineName,
projects,
agents,
defaultProjectId,
defaultAssigneeAgentId,
variables,
isPending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
companyId: string | null | undefined;
[codex] improve issue and routine UI responsiveness (#3744) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators rely on issue, inbox, and routine views to understand what the company is doing in real time > - Those views need to stay fast and readable even when issue lists, markdown comments, and run metadata get large > - The current branch had a coherent set of UI and live-update improvements spread across issue search, issue detail rendering, routine affordances, and workspace lookups > - This pull request groups those board-facing changes into one standalone branch that can merge independently of the heartbeat/runtime work > - The benefit is a faster, clearer issue and routine workflow without changing the underlying task model ## What Changed - Show routine execution issues by default and rename the filter to `Hide routine runs` so the default state no longer looks like an active filter. - Show the routine name in the run dialog and tighten the issue properties pane with a workspace link, copy-on-click behavior, and an inline parent arrow. - Reduce issue detail rerenders, keep queued issue chat mounted, improve issues page search responsiveness, and speed up issues first paint. - Add inbox "other search results", refresh visible issue runs after status updates, and optimize workspace lookups through summary-mode execution workspace queries. - Improve markdown wrapping and scrolling behavior for long strings and self-comment code blocks. - Relax the markdown sanitizer assertion so the test still validates safety after the new wrap-friendly inline styles. ## Verification - `pnpm vitest run ui/src/components/IssuesList.test.tsx ui/src/lib/inbox.test.ts ui/src/pages/Issues.test.tsx ui/src/context/BreadcrumbContext.test.tsx ui/src/context/LiveUpdatesProvider.test.ts ui/src/components/MarkdownBody.test.tsx ui/src/api/execution-workspaces.test.ts server/src/__tests__/execution-workspaces-routes.test.ts` ## Risks - This touches several issue-facing UI surfaces at once, so regressions would most likely show up as stale rendering, search result mismatches, or small markdown presentation differences. - The workspace lookup optimization depends on the summary-mode route shape staying aligned between server and UI. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 15:54:05 -05:00
routineName?: string | null;
projects: Project[];
agents: Agent[];
defaultProjectId?: string | null;
defaultAssigneeAgentId?: string | null;
variables: RoutineVariable[];
isPending: boolean;
onSubmit: (data: RoutineRunDialogSubmitData) => void;
}) {
const [values, setValues] = useState<Record<string, unknown>>({});
const [selection, setSelection] = useState(() => buildInitialRunSelection({
defaultAssigneeAgentId,
defaultProjectId,
}));
const selectedProject = useMemo(
() => projects.find((project) => project.id === selection.projectId) ?? null,
[projects, selection.projectId],
);
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [open]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
agents.filter((agent) => agent.status !== "terminated"),
recentAssigneeIds,
).map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents, recentAssigneeIds],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() => projects.map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[projects],
);
const currentAssignee = selection.assigneeAgentId
? agents.find((agent) => agent.id === selection.assigneeAgentId) ?? null
: null;
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
selectedProject,
experimentalSettings?.enableIsolatedWorkspaces === true,
);
useEffect(() => {
if (!open) return;
setValues(buildInitialValues(variables));
const nextSelection = buildInitialRunSelection({ defaultAssigneeAgentId, defaultProjectId });
setSelection(nextSelection);
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
setWorkspaceConfigValid(true);
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
const missingRequired = useMemo(
() =>
variables
.filter((variable) => variable.required)
.filter((variable) => isMissingRequiredValue(values[variable.name]))
.map((variable) => variable.label || variable.name),
[values, variables],
);
const workspaceIssue = useMemo(() => ({
companyId: companyId ?? null,
projectId: selectedProject?.id ?? null,
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
currentExecutionWorkspace: null,
}), [
companyId,
selectedProject?.id,
workspaceConfig.executionWorkspaceId,
workspaceConfig.executionWorkspacePreference,
workspaceConfig.executionWorkspaceSettings,
workspaceConfig.projectWorkspaceId,
]);
const canSubmit =
selection.assigneeAgentId.trim().length > 0 &&
missingRequired.length === 0 &&
(!workspaceSelectionEnabled || workspaceConfigValid);
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
}, []);
const handleWorkspaceDraftChange = useCallback((
data: Record<string, unknown>,
meta: { canSave: boolean },
) => {
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
}, []);
return (
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
<DialogContent className="max-w-xl">
<DialogHeader>
[codex] improve issue and routine UI responsiveness (#3744) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators rely on issue, inbox, and routine views to understand what the company is doing in real time > - Those views need to stay fast and readable even when issue lists, markdown comments, and run metadata get large > - The current branch had a coherent set of UI and live-update improvements spread across issue search, issue detail rendering, routine affordances, and workspace lookups > - This pull request groups those board-facing changes into one standalone branch that can merge independently of the heartbeat/runtime work > - The benefit is a faster, clearer issue and routine workflow without changing the underlying task model ## What Changed - Show routine execution issues by default and rename the filter to `Hide routine runs` so the default state no longer looks like an active filter. - Show the routine name in the run dialog and tighten the issue properties pane with a workspace link, copy-on-click behavior, and an inline parent arrow. - Reduce issue detail rerenders, keep queued issue chat mounted, improve issues page search responsiveness, and speed up issues first paint. - Add inbox "other search results", refresh visible issue runs after status updates, and optimize workspace lookups through summary-mode execution workspace queries. - Improve markdown wrapping and scrolling behavior for long strings and self-comment code blocks. - Relax the markdown sanitizer assertion so the test still validates safety after the new wrap-friendly inline styles. ## Verification - `pnpm vitest run ui/src/components/IssuesList.test.tsx ui/src/lib/inbox.test.ts ui/src/pages/Issues.test.tsx ui/src/context/BreadcrumbContext.test.tsx ui/src/context/LiveUpdatesProvider.test.ts ui/src/components/MarkdownBody.test.tsx ui/src/api/execution-workspaces.test.ts server/src/__tests__/execution-workspaces-routes.test.ts` ## Risks - This touches several issue-facing UI surfaces at once, so regressions would most likely show up as stale rendering, search result mismatches, or small markdown presentation differences. - The workspace lookup optimization depends on the summary-mode route shape staying aligned between server and UI. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 15:54:05 -05:00
{routineName && (
<p className="text-muted-foreground text-sm">{routineName}</p>
)}
<DialogTitle>Run routine</DialogTitle>
<DialogDescription>
Choose the agent and optional project for this one run. Routine defaults are prefilled and won&apos;t be changed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Agent *</Label>
<InlineEntitySelector
value={selection.assigneeAgentId}
options={assigneeOptions}
placeholder="Agent"
noneLabel="Select an agent"
searchPlaceholder="Search agents..."
emptyMessage="No agents found."
disablePortal
openOnFocus={false}
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setSelection((current) => ({ ...current, assigneeAgentId }));
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Select an agent</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agents.find((agent) => agent.id === option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Project</Label>
<InlineEntitySelector
value={selection.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
disablePortal
openOnFocus={false}
onChange={(projectId) => {
const project = projects.find((entry) => entry.id === projectId) ?? null;
setSelection((current) => ({ ...current, projectId }));
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
setWorkspaceConfigValid(true);
}}
renderTriggerValue={(option) =>
option && selectedProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: selectedProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">No project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projects.find((entry) => entry.id === option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
{variables.map((variable) => (
<div key={variable.name} className="space-y-1.5">
<Label className="text-xs">
{variable.label || variable.name}
{variable.required ? " *" : ""}
</Label>
{variable.type === "textarea" ? (
<Textarea
rows={4}
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
/>
) : variable.type === "boolean" ? (
<Select
value={values[variable.name] === true ? "true" : values[variable.name] === false ? "false" : "__unset__"}
onValueChange={(next) => setValues((current) => ({
...current,
[variable.name]: next === "__unset__" ? "" : next === "true",
}))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">No value</SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : variable.type === "select" ? (
<Select
value={typeof values[variable.name] === "string" && values[variable.name] ? values[variable.name] as string : "__unset__"}
onValueChange={(next) => setValues((current) => ({
...current,
[variable.name]: next === "__unset__" ? "" : next,
}))}
>
<SelectTrigger>
<SelectValue placeholder="Choose a value" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">No value</SelectItem>
{variable.options.map((option) => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={variable.type === "number" ? "number" : "text"}
value={values[variable.name] == null ? "" : String(values[variable.name])}
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
/>
)}
</div>
))}
{workspaceSelectionEnabled && selectedProject && companyId ? (
<IssueWorkspaceCard
key={`${open ? "open" : "closed"}:${selectedProject.id}`}
issue={workspaceIssue}
project={selectedProject}
initialEditing
livePreview
onUpdate={handleWorkspaceUpdate}
onDraftChange={handleWorkspaceDraftChange}
/>
) : null}
</div>
<DialogFooter showCloseButton={false}>
{!selection.assigneeAgentId ? (
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
) : missingRequired.length > 0 ? (
<p className="mr-auto text-xs text-amber-600">
Missing: {missingRequired.join(", ")}
</p>
) : workspaceSelectionEnabled && !workspaceConfigValid ? (
<p className="mr-auto text-xs text-amber-600">
Choose an existing workspace before running.
</p>
) : (
<span className="mr-auto" />
)}
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button
onClick={() => {
const nextVariables: Record<string, string | number | boolean> = {};
for (const variable of variables) {
const rawValue = values[variable.name];
if (isMissingRequiredValue(rawValue)) continue;
if (variable.type === "number") {
nextVariables[variable.name] = Number(rawValue);
} else if (variable.type === "boolean") {
nextVariables[variable.name] = rawValue === true;
} else {
nextVariables[variable.name] = String(rawValue);
}
}
onSubmit({
variables: nextVariables,
assigneeAgentId: selection.assigneeAgentId,
projectId: selection.projectId || null,
...(workspaceSelectionEnabled
? {
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
}
: {}),
});
}}
disabled={isPending || !canSubmit}
>
{isPending ? "Running..." : "Run routine"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}