feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
GitBranch,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -48,7 +48,7 @@ import type { Lane, Project, Session } from "../../lib/types";
|
||||
import { fmtCost, timeAgo } from "../../lib/format";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelPicker, PromptEditor } from "../run/RunSetup";
|
||||
import { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
||||
import { Select } from "../Select";
|
||||
@@ -85,18 +85,6 @@ function fmtBytes(n: number | null): string {
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** One labelled field of the prompt box's row. */
|
||||
function Field({ label, width, children }: { label: string; width: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className={width}>
|
||||
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HomeProps = Parameters<typeof HomeBody>[0];
|
||||
|
||||
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
||||
@@ -127,6 +115,7 @@ function HomeBody({
|
||||
const claude = tool === "claude";
|
||||
const codex = tool === "codex";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [router, setRouter] = useSessionRouter();
|
||||
const routers = useAiRouters();
|
||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
||||
@@ -283,7 +272,7 @@ function HomeBody({
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", {
|
||||
slot,
|
||||
prompt: prompt.trim(),
|
||||
prompt: promptWithImages(prompt, images),
|
||||
// Only a different tool is named: the workspace's own keeps starting
|
||||
// even after it was disabled in Settings.
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
@@ -296,6 +285,8 @@ function HomeBody({
|
||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||
});
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
@@ -407,7 +398,10 @@ function HomeBody({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card mt-5 p-3">
|
||||
<div
|
||||
data-testid="workspace-home-composer"
|
||||
className="mt-5 rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50"
|
||||
>
|
||||
<PromptEditor
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
@@ -416,117 +410,132 @@ function HomeBody({
|
||||
rows={3}
|
||||
slash={claude}
|
||||
fileCwd={lane.cwd}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
bare
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<Field label={t("home.ai")} width="w-44">
|
||||
<div data-testid="workspace-home-tool" className="flex items-center gap-1.5">
|
||||
<AiToolIcon
|
||||
tool={{ id: tool, label: aiTools.label(tool) }}
|
||||
className="h-6 w-6 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Select<string>
|
||||
value={tool}
|
||||
onChange={(v) => {
|
||||
setTool(v);
|
||||
setBypass(false);
|
||||
}}
|
||||
options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t("router.label")} width="w-44">
|
||||
<RouterPicker tool={tool} routers={routers} value={router} onChange={setRouter} />
|
||||
</Field>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-0.5">
|
||||
<div data-testid="workspace-home-tool">
|
||||
<Select<string>
|
||||
variant="ghost"
|
||||
label={t("home.ai")}
|
||||
value={tool}
|
||||
onChange={(v) => {
|
||||
setTool(v);
|
||||
setBypass(false);
|
||||
}}
|
||||
options={toolOptions.map((id) => ({
|
||||
value: id,
|
||||
label: aiTools.label(id),
|
||||
icon: (
|
||||
<AiToolIcon
|
||||
tool={{ id, label: aiTools.label(id) }}
|
||||
className="h-4 w-4 !p-0.5"
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<RouterPicker
|
||||
variant="ghost"
|
||||
tool={tool}
|
||||
routers={routers}
|
||||
value={router}
|
||||
onChange={setRouter}
|
||||
/>
|
||||
{hasModel && (
|
||||
<Field label={t("run:fields.model")} width="w-56">
|
||||
<ModelPicker value={model} onChange={setModel} tool={tool} choices={routerModels} />
|
||||
</Field>
|
||||
<ModelPicker
|
||||
variant="ghost"
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
tool={tool}
|
||||
choices={routerModels}
|
||||
/>
|
||||
)}
|
||||
{claude && (
|
||||
<Field label={t("run:fields.permissionMode")} width="w-44">
|
||||
<Select<PermissionMode>
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: "auto", label: t("run:fields.permissionAuto") },
|
||||
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("run:fields.permissionDefault") },
|
||||
{ value: "plan", label: t("run:fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Select<PermissionMode>
|
||||
variant="ghost"
|
||||
label={t("run:fields.permissionMode")}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: "auto", label: t("run:fields.permissionAuto") },
|
||||
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("run:fields.permissionDefault") },
|
||||
{ value: "plan", label: t("run:fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{codex && (
|
||||
<>
|
||||
<Field label={t("run:fields.sandbox")} width="w-40">
|
||||
<Select<CodexSandbox>
|
||||
value={sandbox}
|
||||
onChange={setSandbox}
|
||||
options={[
|
||||
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("run:fields.approval")} width="w-32">
|
||||
<Select<CodexApproval>
|
||||
value={approval}
|
||||
onChange={setApproval}
|
||||
options={[
|
||||
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("run:fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Select<CodexSandbox>
|
||||
variant="ghost"
|
||||
label={t("run:fields.sandbox")}
|
||||
value={sandbox}
|
||||
onChange={setSandbox}
|
||||
options={[
|
||||
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
<Select<CodexApproval>
|
||||
variant="ghost"
|
||||
label={t("run:fields.approval")}
|
||||
value={approval}
|
||||
onChange={setApproval}
|
||||
options={[
|
||||
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("run:fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{hasEffort && (
|
||||
<Field label={t("run:fields.effort")} width="w-40">
|
||||
<Select<EffortLevel>
|
||||
value={effort}
|
||||
onChange={setEffort}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
<Select<EffortLevel>
|
||||
variant="ghost"
|
||||
label={t("run:fields.effort")}
|
||||
value={effort}
|
||||
onChange={setEffort}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||
{canBypass && (
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("run:fields.bypass")}
|
||||
labelClassName="text-xs text-status-danger"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{canBypass && (
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("run:fields.bypass")}
|
||||
labelClassName="text-xs text-status-danger"
|
||||
/>
|
||||
)}
|
||||
{claude && project?.root && (
|
||||
<Checkbox
|
||||
checked={inWorktree}
|
||||
onChange={setInWorktree}
|
||||
label={t("home.worktree")}
|
||||
title={t("home.worktreeHint")}
|
||||
className="ml-1"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-home-start"
|
||||
onClick={() => void start()}
|
||||
disabled={starting}
|
||||
className="btn-primary ml-auto text-xs disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t("home.start")}
|
||||
</button>
|
||||
)}
|
||||
{claude && project?.root && (
|
||||
<Checkbox
|
||||
checked={inWorktree}
|
||||
onChange={setInWorktree}
|
||||
label={t("home.worktree")}
|
||||
title={t("home.worktreeHint")}
|
||||
labelClassName="text-xs text-fg-secondary"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-home-start"
|
||||
onClick={() => void start()}
|
||||
disabled={starting}
|
||||
aria-label={t("home.start")}
|
||||
title={t("home.start")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{danger && (
|
||||
|
||||
Reference in New Issue
Block a user