From 69dd7dd3b17cf40a86c8f0ed93617df568a18699 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:18:59 +0800 Subject: [PATCH 1/3] refactor(workspace): keep sending controls in the composer Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/workspace-scope-browser-smoke.mjs | 64 +++++++++++++++- .../personal-workspace/channel-header.tsx | 36 --------- .../personal-workspace/chat-model-menu.tsx | 4 +- .../personal-workspace/composer-controls.tsx | 57 +++++++++++++++ .../personal-workspace-page.tsx | 69 ++++++++++-------- .../personal-workspace/personal-workspace.css | 66 +++++++++++++---- .../workspace-theme.test.mjs | 4 +- .../dashboard/src/views/dashboard-page.tsx | 10 ++- docs/development/design.md | 16 ++++ .../agent-model-selection.mjs | 19 +++++ .../conversation-activity.mjs | 2 +- .../conversation-input.mjs | 1 + .../unified-composer/after-actions-mobile.png | Bin 0 -> 70355 bytes .../after-attachment-mobile.png | Bin 0 -> 70189 bytes .../unified-composer/after-desktop.png | Bin 0 -> 142491 bytes .../unified-composer/after-mobile.png | Bin 0 -> 71975 bytes .../unified-composer/after-model-desktop.png | Bin 0 -> 157684 bytes .../unified-composer/after-model-mobile.png | Bin 0 -> 62813 bytes .../unified-composer/before-desktop.png | Bin 0 -> 145606 bytes .../unified-composer/before-mobile.png | Bin 0 -> 71885 bytes .../unified-composer/before-model-desktop.png | Bin 0 -> 156541 bytes .../unified-composer/before-model-mobile.png | Bin 0 -> 75112 bytes .../goal-suggestions-live.mjs | 11 ++- .../goal-suggestions.mjs | 4 +- 24 files changed, 264 insertions(+), 99 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/after-actions-mobile.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/after-attachment-mobile.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/after-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/after-mobile.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/after-model-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/after-model-mobile.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/before-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/before-mobile.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/before-model-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/unified-composer/before-model-mobile.png diff --git a/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs index f5f745b114..639eeb3742 100644 --- a/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs @@ -3,7 +3,7 @@ // fake Codex app-server stand in for the host; no model, registry or Goal is used. import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, rename, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, rename, rm, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { resolveTestPython } from "../../../../scripts/test-python.mjs"; @@ -18,11 +18,15 @@ const workspace = join(root, "notes"); await mkdir(workspace); await mkdir(join(root, "home")); const codex = join(root, "codex"); +const packetLog = join(root, "native-packets.jsonl"); const written = spawnSync(python, ["-c", [ "import pathlib, runpy, sys", "source = runpy.run_path('examples/loopx-chat-runtime-smoke.py')['FAKE_CODEX']", + "source = source.replace('active_turn = None', 'if sys.argv[1:] == [\"debug\", \"models\"]:\\n print(json.dumps({\\'models\\': [{\\'slug\\': \\'trial-native-model\\', \\'display_name\\': \\'Trial native model\\', \\'supported_reasoning_levels\\': [{\\'effort\\': \\'low\\'}, {\\'effort\\': \\'high\\'}]}, {\\'slug\\': \\'rejected-model\\', \\'display_name\\': \\'Rejected model\\', \\'supported_reasoning_levels\\': [{\\'effort\\': \\'high\\'}]}]}))\\n sys.exit(0)\\nactive_turn = None', 1)", + "source = source.replace(' method = request.get(\"method\")', f' with open({sys.argv[2]!r}, \"a\") as output:\\n output.write(json.dumps(request) + \"\\\\n\")\\n method = request.get(\"method\")')", + "source = source.replace(' result = {\"thread\": {\"id\": \"durable-thread\"}}', ' result = {\"thread\": {\"id\": \"durable-thread\"}, \"model\": \"rejected-readback\" if request[\"params\"].get(\"model\") == \"rejected-model\" else request[\"params\"].get(\"model\", \"old-model\"), \"reasoningEffort\": request[\"params\"].get(\"config\", {}).get(\"model_reasoning_effort\", \"xhigh\")}')", "path = pathlib.Path(sys.argv[1]); path.write_text(source); path.chmod(0o700)", -].join("\n"), codex], { cwd: repoRoot, encoding: "utf8" }); +].join("\n"), codex, packetLog], { cwd: repoRoot, encoding: "utf8" }); assert.equal(written.status, 0, written.stderr); let serverError = ""; @@ -116,7 +120,61 @@ try { assert.equal(await page.getByRole("button", { name: "发送", exact: true }).isDisabled(), true, "A revoked grant keeps history and blocks new messages"); await capture(page, "ordinary-workspace-grant-rejection.png"); - console.log("workspace scope browser smoke ok"); + // Recover the same grant, then use the production HTTP/store/native path. + // The provider is deterministic: no model or live workspace is involved. + await rename(`${workspace}.moved`, workspace); + await page.reload({waitUntil: "networkidle"}); + const composer = page.getByLabel("发送消息"); + await composer.waitFor(); + assert.equal(await page.locator(".personal-channel-header .personal-chat-model-menu").count(), 0); + const menu = page.locator(".personal-channel-composer .personal-chat-model-menu"); + const actions = page.getByLabel("添加附件与快捷操作", {exact: true}); + await composer.fill("保留草稿,先核对配置。"); + const beforeActions = sessionRequests.length; + await actions.click(); + await page.getByRole("button", {name: "添加图片", exact: true}).waitFor(); + await page.keyboard.press("Escape"); + assert.equal(await actions.evaluate(node => node === document.activeElement), true); + assert.equal(await composer.inputValue(), "保留草稿,先核对配置。"); + assert.equal(sessionRequests.length, beforeActions, "Menu open/dismiss is read-only"); + await menu.locator("summary").click(); + await page.getByRole("combobox", {name: "选择模型", exact: true}).click(); + await page.getByRole("option", {name: "Trial native model", exact: true}).click(); + await page.getByRole("combobox", {name: "选择思考深度", exact: true}).click(); + await page.getByRole("option", {name: "high", exact: true}).click(); + await page.keyboard.press("Escape"); + await composer.press("Enter"); + await menu.locator(".personal-model-current").filter({hasText: "trial-native-model"}).waitFor(); + assert.equal(sessionRequests.at(-1).agent_model_id, "trial-native-model"); + assert.equal(sessionRequests.at(-1).agent_reasoning_effort, "high"); + const packets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.ok(packets.some(row => row.method === "thread/start" && row.params.model === "trial-native-model" + && row.params.config.model_reasoning_effort === "high")); + await page.locator(".personal-message-pending").waitFor({state: "hidden"}); + const completedPackets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.ok(completedPackets.some(row => row.method === "turn/start" && row.params.effort === "high")); + // This configuration owner still creates a new Session; layout must not + // claim the separately requested same-Session configuration is implemented. + await menu.locator("summary").click(); + await page.getByText("新对话配置", {exact: true}).waitFor(); + await page.getByRole("combobox", {name: "选择模型", exact: true}).click(); + await page.getByRole("option", {name: "Rejected model", exact: true}).click(); + await page.keyboard.press("Escape"); + await composer.fill("配置被拒绝时保留这条消息。"); + const rejection = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat/sessions" && response.status() === 424); + await composer.press("Enter"); + assert.equal((await rejection).status(), 424); + await page.waitForFunction(() => document.querySelector("textarea")?.value === "配置被拒绝时保留这条消息。"); + await page.locator(".personal-message-pending").waitFor({state: "hidden"}); + assert.equal(await composer.inputValue(), "配置被拒绝时保留这条消息。"); + assert.equal(await menu.locator(".personal-model-current").innerText(), "trial-native-model", + "A rejected selection cannot replace actual model readback"); + const refusedPackets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.equal(refusedPackets.filter(row => row.method === "turn/start").length, + completedPackets.filter(row => row.method === "turn/start").length, + "Rejected Session configuration cannot dispatch or replay the message"); + await capture(page, "composer-configuration-rejected.png"); + console.log("workspace scope browser smoke ok; composer actions, actual native model/effort and rejection retained"); } finally { await browser?.close(); await stopServer(); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 06369bdded..0d4b5d8ee2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -5,7 +5,6 @@ import { useWorkspaceI18n } from "./i18n"; import type { ManagerChannelBinding, ManagerRuntimeSessionReadback } from "../../data/chat"; import type { WorkspaceAgentOption, WorkspaceGoal, WorkspaceGoalTab } from "./personal-workspace-model"; import { WorkspaceSelect } from "./workspace-select"; -import { ChatModelMenu } from "./chat-model-menu"; import type {ReactNode} from "react"; export function ChannelHeader({ @@ -23,23 +22,17 @@ export function ChannelHeader({ allWorkOpen, onOpenManagerChat, onOpenManagerSettings, - onOpenConversationSettings, onOpenWorkspace, onRefresh, onRequestGoalLifecycle, onOpenNavigation, onSelectGoalTab, onSelectAgent, - onSelectAgentModel, - onSelectAgentReasoningEffort, onReturnManagerHome, refreshState, readOnlySourceLabel, resumeReviewed, selectedAgentId, - selectedAgentModelId, - selectedAgentModelReadback, - selectedAgentReasoningEffort, selectedGoal, selectedGoalTab, scopeUnavailable = false, @@ -70,7 +63,6 @@ export function ChannelHeader({ allWorkOpen?: boolean; onOpenManagerChat?: () => void; onOpenManagerSettings?: () => void; - onOpenConversationSettings?: () => void; onOpenWorkspace?: () => void; onRefresh?: () => void; /** Absent when the selected source does not authorize Goal lifecycle changes. */ @@ -78,17 +70,12 @@ export function ChannelHeader({ onOpenNavigation?: () => void; onSelectGoalTab: (tab: WorkspaceGoalTab) => void; onSelectAgent: (agentId: string) => void; - onSelectAgentModel?: (modelId: string) => void; - onSelectAgentReasoningEffort?: (effort: string) => void; onReturnManagerHome?: () => void; refreshState?: "idle" | "loading" | "done" | "error"; readOnlySourceLabel?: string; /** Resume opens the owner's reviewed preview instead of a local confirmation. */ resumeReviewed?: boolean; selectedAgentId: string; - selectedAgentModelId?: string; - selectedAgentModelReadback?: import("../../data/chat").ChatSessionSummary; - selectedAgentReasoningEffort?: string; selectedGoal: WorkspaceGoal | null; selectedGoalTab: WorkspaceGoalTab; /** A requested Goal scope failed; do not present the steward as its fallback. */ @@ -169,21 +156,6 @@ export function ChannelHeader({ const runtimeControl = scopeUnavailable || agentWorkActive ? null : readOnlySourceLabel ? ( {selectedGoal?.title === readOnlySourceLabel ? null : readOnlySourceLabel}{t("common.readOnly")} ) : selectedGoal && !composerVisible ? null : <> - {ordinaryConversation ? } - onChange={onSelectAgent} - options={[ - ...(statusOnly ? [{ value: "", label: locale === "zh-CN" ? "选择执行器" : "Choose executor", disabled: true }] : []), - ...agents.filter((agent) => agent.agentId !== "status-only").map((agent) => ({ - disabled: !agent.available, - label: `${agent.label}${agent.available ? "" : ` · ${t("header.agentUnavailable")}`}`, - value: agent.agentId, - }))]} - prefixLabel={selectedGoal ? (locale === "zh-CN" ? "执行器" : "Executor") : t("header.chatRuntime")} - value={statusOnly ? "" : selectedAgentId} - /> : null} {agents.some((agent) => agent.agentId === "status-only") ? : null} - {/* The steward's machine binding owns its model; Goal defaults cannot label it. */} - {!statusOnly && ordinaryConversation ? : null} ; const executionChipClass = managerExecutionUnavailable @@ -229,8 +195,6 @@ export function ChannelHeader({ const sourceRuntimeControl = !originalConversationTitle || scopeUnavailable || agentWorkActive || readOnlySourceLabel ? runtimeControl : <> {selectedAgentId} - ; return ( diff --git a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx index 83a439795a..d50975e08b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx @@ -31,8 +31,8 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r return () => controller.abort(); }, [open, ordinary, agentId, goalId, model, retry, zh]); useEffect(() => { - if (!open) return; const close = (event: PointerEvent | KeyboardEvent) => { + if (!root.current?.open) return; if (event instanceof KeyboardEvent) { if (event.key !== "Escape" || event.defaultPrevented) return; root.current?.querySelector("summary")?.focus(); @@ -42,7 +42,7 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r document.addEventListener("pointerdown", close); document.addEventListener("keydown", close); return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", close); }; - }, [open]); + }, []); const controls = chatModelControls(discovery, model, effort, readback); const observed = controls.observedModel ?? (zh ? "模型设置" : "Model settings"); const observedSettings = controls.observedModel ? [controls.observedEffort, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx b/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx new file mode 100644 index 0000000000..4a9138fdaa --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx @@ -0,0 +1,57 @@ +import { Bot, Plus } from "lucide-react"; +import { useEffect, useRef, type ReactNode } from "react"; +import type { ChatSessionSummary } from "../../data/chat"; +import type { WorkspaceAgentOption } from "./personal-workspace-model"; +import { ChatModelMenu } from "./chat-model-menu"; +import { useWorkspaceI18n } from "./i18n"; +import { WorkspaceSelect } from "./workspace-select"; + +/** Existing send controls keep their Session/configuration owners. */ +export function ComposerRuntimeControls({ agents, agentId, goalId, original, model, effort, readback, onAgent, onModel, onEffort, onDefaults }: { + agents: WorkspaceAgentOption[]; agentId: string; goalId?: string; original: boolean; + model?: string; effort?: string; readback?: ChatSessionSummary; + onAgent: (id: string) => void; onModel?: (id: string) => void; + onEffort?: (effort: string) => void; onDefaults?: () => void; +}) { + const { locale, t } = useWorkspaceI18n(); + const statusOnly = agentId === "status-only"; + return
+ {original ? {agentId} + : } onChange={onAgent} value={statusOnly ? "" : agentId} + options={[ + ...(statusOnly ? [{value: "", label: locale === "zh-CN" ? "选择执行器" : "Choose executor", disabled: true}] : []), + ...agents.filter(agent => agent.agentId !== "status-only").map(agent => ({value: agent.agentId, + label: `${agent.label}${agent.available ? "" : ` · ${t("header.agentUnavailable")}`}`, disabled: !agent.available})), + ]} />} + {!statusOnly ? : null} +
; +} + +/** One optional action entry; opening and dismissing it never sends a message. */ +export function ComposerActions({ children }: { children: ReactNode }) { + const { locale } = useWorkspaceI18n(); + const root = useRef(null); + useEffect(() => { + const close = (event: PointerEvent | KeyboardEvent) => { + if (!root.current?.open) return; + if (event instanceof KeyboardEvent) { + if (event.key !== "Escape" || event.defaultPrevented) return; + event.preventDefault(); + root.current?.querySelector("summary")?.focus(); + } else if (root.current?.contains(event.target as Node)) return; + if (root.current) root.current.open = false; + }; + document.addEventListener("pointerdown", close); + document.addEventListener("keydown", close); + return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", close); }; + }, []); + return
+ +
{ + if ((event.target as Element).closest("button:not(:disabled)") && root.current) root.current.open = false; + }}>{children}
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index cb507b84ba..e83712ead2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -46,6 +46,7 @@ import { import { useTypedActionReadback, useTypedActionProposalReadback } from "../../data/use-typed-action-readback"; import { ChannelHeader } from "./channel-header"; +import { ComposerActions, ComposerRuntimeControls } from "./composer-controls"; import { GoalLoopXMode } from "./goal-loopx-mode"; import { GoalTeamResults } from "./goal-team-results"; import { GoalManagedResults } from "./goal-managed-results"; @@ -2633,15 +2634,7 @@ export function PersonalWorkspacePage({ } }, [attentionKeySignature, attentionNotificationsActive, attentionNotificationSupportState, t]); - const composerSuggestions = (!originalConversation || !managerChatOpen) && !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{ - if (event.key !== "Escape") return; - event.currentTarget.open = false; - event.currentTarget.querySelector("summary")?.focus(); - }}> - {selectedGoal && goalSuggestions.length - ? t("suggestions.summary", { first: goalSuggestionLabel(goalSuggestions[0]) }) - : locale === "zh-CN" ? "快捷提问" : "Suggestions"} -
+ const composerSuggestions = (!originalConversation || !managerChatOpen) && !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{selectedGoal && goalSuggestions.length ? (
{goalSuggestions.map((suggestion) => ( @@ -2666,7 +2659,7 @@ export function PersonalWorkspacePage({
)}
-
: null; + : null; const taskBoard = (active: boolean) => selectedGoal ? ( setSelection({ kind: "goal", item: selectedGoal }) : undefined} onOpenGoalCapabilities={selectedGoal && !readOnly ? () => openSettings({ goalId: selectedGoal.goalId, kind: "settings", tab: "capabilities" }) : undefined} onOpenManagerSettings={!readOnly ? () => openSettings({ kind: "settings", tab: "steward" }) : undefined} - onOpenConversationSettings={!readOnly && !originalConversation ? () => openSettings({kind: "settings", tab: "conversation"}, true) : undefined} onRefresh={callbacks.onRefresh && !runtimeSourceUnavailable ? () => void refreshWorkspace() : undefined} onOpenNavigation={() => setMobileSidebarOpen(true)} onOpenManagerChat={() => { @@ -2787,8 +2779,6 @@ export function PersonalWorkspacePage({ else setSelectedGoalTab(tab); }} onSelectAgent={selectAgent} - onSelectAgentModel={(modelId) => callbacks.onSelectAgentModel?.(selectedAgentId, modelId)} - onSelectAgentReasoningEffort={(effort) => callbacks.onSelectAgentReasoningEffort?.(selectedAgentId, effort)} onReturnManagerHome={() => { if (selectedWorkspaceRef || originalConversation) workspaceConversations?.onSelect(null); setSelectedGoalTab("overview"); @@ -2796,9 +2786,6 @@ export function PersonalWorkspacePage({ window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 })); }} selectedAgentId={selectedAgentId} - selectedAgentModelId={selectedAgentModelId} - selectedAgentModelReadback={selectedAgentModelReadback} - selectedAgentReasoningEffort={selectedAgentReasoningEffort} refreshState={refreshState} readOnlySourceLabel={readOnly ? sourceGoal?.sourceAccess?.label ?? statusSourceControl?.activeSource.label : undefined} selectedGoal={runtimeSourceUnavailable ? null : selectedGoal} @@ -2962,7 +2949,8 @@ export function PersonalWorkspacePage({ : null} ) : null} - {loopxMode?.session_id === conversationSessionId && loopxMode?.enabled && loopxMode.active_turn_id ? : null} + + {loopxMessageReceipt ?

{loopxMessageReceipt}

: null} {readOnly ? ( sourceGoal?.sourceAccess ? null :
{t("source.readOnlyNoticeTitle")}{t("source.readOnlyNoticeDescription")}
) : <> @@ -3010,7 +2998,7 @@ export function PersonalWorkspacePage({ {t("composer.goalPrompt")} : null} - {composerSuggestions} + {composerCollapsed && composerSuggestions ? {composerSuggestions} : null} {!composerCollapsed ? <> {actionDraft ? setActionDraft(null)} onPreview={async (request) => { @@ -3068,16 +3056,6 @@ export function PersonalWorkspacePage({ void selectImages(images); }} > - = maxImageAttachmentCount} multiple onChange={(event) => void selectImages(event.target.files)} ref={imageInputRef} type="file" />