feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* @file SessionReplay — an ENDED session opened in its workspace
|
||||
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
|
||||
* an old one: its conversation, read-only (`ConversationView`), and a prompt
|
||||
* box under it. Opening it resumes nothing; only SENDING a message does —
|
||||
* box under it (a pasted image rides along as a thumbnail). Opening it resumes nothing; only SENDING a message does —
|
||||
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the
|
||||
* first free pane slot, after which `onResumed` opens the pane (`&run=`). The
|
||||
* session's tool and folder come from its row (`GET /api/sessions/:id`), so a
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Send } from "lucide-react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { PromptEditor } from "../run/RunSetup";
|
||||
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { freeSlot, paneRunId } from "./WorkspaceHome";
|
||||
|
||||
export function SessionReplay({
|
||||
@@ -35,6 +35,7 @@ export function SessionReplay({
|
||||
const { t } = useTranslation("lanes");
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -42,6 +43,7 @@ export function SessionReplay({
|
||||
let live = true;
|
||||
setSession(null);
|
||||
setPrompt("");
|
||||
setImages([]);
|
||||
setError(null);
|
||||
api.sessions
|
||||
.get(sessionId)
|
||||
@@ -53,7 +55,7 @@ export function SessionReplay({
|
||||
}, [sessionId]);
|
||||
|
||||
const send = async () => {
|
||||
const text = prompt.trim();
|
||||
const text = promptWithImages(prompt, images);
|
||||
if (!text || busy) return;
|
||||
const slot = freeSlot(lane.id, liveRuns);
|
||||
if (slot === null) {
|
||||
@@ -69,6 +71,8 @@ export function SessionReplay({
|
||||
prompt: text,
|
||||
});
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
onResumed(r.run_id ?? paneRunId(lane.id, slot));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -82,8 +86,11 @@ export function SessionReplay({
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface-1 p-2">
|
||||
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={() => void send()}
|
||||
@@ -98,11 +105,12 @@ export function SessionReplay({
|
||||
type="button"
|
||||
data-testid="session-replay-send"
|
||||
onClick={() => void send()}
|
||||
disabled={busy || !prompt.trim()}
|
||||
className="btn-primary ml-auto text-xs disabled:opacity-50"
|
||||
disabled={busy || (!prompt.trim() && images.length === 0)}
|
||||
aria-label={t("replay.send")}
|
||||
title={t("replay.send")}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
{t("replay.send")}
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -119,7 +119,7 @@ export function SessionResumeButton({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{run ? (
|
||||
<button type="button" onClick={() => open(run.id)} className="btn-ghost text-xs">
|
||||
<SquareTerminal className="h-4 w-4" />
|
||||
@@ -148,7 +148,7 @@ export function SessionResumeButton({
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="max-w-xs text-right text-[11px] text-status-danger">
|
||||
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -19,6 +19,8 @@ vi.mock("../../conversation/ConversationView", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("../../run/RunSetup", () => ({
|
||||
promptWithImages: (text: string, images: { path: string }[]) =>
|
||||
[text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "),
|
||||
PromptEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
|
||||
@@ -19,6 +19,8 @@ import type { Lane, Session } from "../../../lib/types";
|
||||
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../run/RunSetup", () => ({
|
||||
promptWithImages: (text: string, images: { path: string }[]) =>
|
||||
[text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "),
|
||||
PromptEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -211,9 +213,11 @@ describe("WorkspaceHome", () => {
|
||||
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const card = screen.getByTestId("workspace-home-start").closest(".card") as HTMLElement;
|
||||
const labels = [...card.querySelectorAll("span.uppercase")].map((x) => x.textContent);
|
||||
expect(labels).toEqual(["AI", "AI Router", "Model", "Permission mode", "Thinking effort"]);
|
||||
const box = screen.getByTestId("workspace-home-composer");
|
||||
const labels = [
|
||||
...box.querySelectorAll("button[aria-haspopup='listbox'], input[aria-label='model']"),
|
||||
].map((x) => x.getAttribute("aria-label"));
|
||||
expect(labels).toEqual(["AI", "AI Router", "model", "Permission mode", "Thinking effort"]);
|
||||
});
|
||||
|
||||
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
|
||||
|
||||
Reference in New Issue
Block a user