feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker

This commit is contained in:
2026-10-02 16:13:27 +07:00
parent 4802684d2f
commit f35bf2043f
15 changed files with 414 additions and 185 deletions
+17 -9
View File
@@ -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>
)}
+126 -117
View File
@@ -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 () => {