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" />
-
-
- {conversationOpen ? {steering
+
+
+
+
+ {composerSuggestions}
+
+
{steering
? (locale === "zh-CN" ? "正在发送…" : "Sending…")
: queueTurnId ? (locale === "zh-CN" ? `Enter 排队 · Shift+Enter 换行${steeringTurnId ? " · Command+Enter 调整本轮" : ""}` : `Enter to queue · Shift+Enter for a new line${steeringTurnId ? " · Command+Enter to steer" : ""}`)
: sending && !steeringTurnId
? (locale === "zh-CN" ? "正在回复 · 可以先保留后续消息草稿" : "Reply in progress · you can draft a follow-up message")
- : (locale === "zh-CN" ? "Enter 发送 · Shift+Enter 换行" : "Enter to send · Shift+Enter for a new line")}
: null}
+ : (locale === "zh-CN" ? "Enter 发送 · Shift+Enter 换行" : "Enter to send · Shift+Enter for a new line")}
+
+ {loopxMode?.session_id === conversationSessionId && loopxMode?.enabled && loopxMode.active_turn_id ?
: null}
+ {(selectedGoal || selectedWorkspaceRef || originalConversation) ?
callbacks.onSelectAgentModel?.(selectedAgentId, model)}
+ onEffort={effort => callbacks.onSelectAgentReasoningEffort?.(selectedAgentId, effort)}
+ onDefaults={selectedGoal && !originalConversation ? () => openSettings({kind: "settings", tab: "conversation"}, true) : undefined}
+ /> : null}
+
+
+
+
> : null}
>}
: null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
index e85f6c2ef0..c8961a6323 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
@@ -1240,14 +1240,12 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-quick-prompts button:disabled { opacity: .45; cursor: default; }
.personal-goal-draft-status { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; padding: 8px 11px; border: 1px solid #cfd9f5; border-radius: 10px; background: #f5f7ff; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
.personal-goal-draft-status strong { color: var(--pw-text); }
-.personal-channel-composer { display: grid; grid-template-columns: 36px minmax(0, 1fr) 40px; align-items: center; min-height: 56px; padding: 7px 7px 7px 14px; border: 1px solid var(--pw-line-strong); border-radius: 16px; background: #fff; box-shadow: 0 2px 10px rgb(30 28 20 / 7%); transition: border-color .15s ease, box-shadow .15s ease; }
+.personal-channel-composer { position: relative; display: grid; gap: 4px; min-height: 92px; padding: 8px; border: 1px solid var(--pw-line-strong); border-radius: 16px; background: #fff; box-shadow: 0 2px 10px rgb(30 28 20 / 7%); transition: border-color .15s ease, box-shadow .15s ease; }
.personal-channel-composer:focus-within { border-color: var(--pw-blue); box-shadow: 0 0 0 3px rgb(47 102 233 / 13%), 0 2px 10px rgb(30 28 20 / 7%); }
-.personal-channel-composer textarea { width: 100%; max-height: 120px; padding: 9px 12px; resize: none; border: 0; border-left: 1px solid var(--pw-line); outline: 0; background: transparent; color: inherit; font: inherit; line-height: var(--leading-body); }
-.personal-channel-composer > button, .personal-correction-composer button { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: var(--pw-blue); color: #fff; cursor: pointer; box-shadow: 0 2px 6px rgb(47 102 233 / 32%); }
-.personal-channel-composer > button:hover { background: var(--pw-blue-ink); }
-.personal-channel-composer > button:disabled, .personal-correction-composer button:disabled { opacity: .4; cursor: default; box-shadow: none; }
-.personal-channel-composer > .personal-composer-attach { width: 34px; height: 34px; border-radius: 9px; background: transparent; color: var(--pw-muted); box-shadow: none; }
-.personal-channel-composer > .personal-composer-attach:hover { background: var(--pw-blue-soft); color: var(--pw-blue-ink); }
+.personal-channel-composer textarea { width: 100%; max-height: 120px; padding: 8px; resize: none; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; line-height: var(--leading-body); }
+.personal-channel-composer .personal-composer-send, .personal-correction-composer button { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: var(--pw-blue); color: #fff; cursor: pointer; box-shadow: 0 2px 6px rgb(47 102 233 / 32%); }
+.personal-channel-composer .personal-composer-send:hover { background: var(--pw-blue-ink); }
+.personal-channel-composer .personal-composer-send:disabled, .personal-correction-composer button:disabled { opacity: .4; cursor: default; box-shadow: none; }
.personal-composer-images { display: flex; gap: 8px; margin: 0 0 8px; overflow-x: auto; }
.personal-composer-images figure { position: relative; flex: 0 0 74px; height: 58px; margin: 0; }
.personal-composer-images img { width: 100%; height: 100%; object-fit: cover; border: 1px solid var(--pw-line-strong); border-radius: 10px; background: #fff; }
@@ -1819,11 +1817,10 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-workspace-shell[data-pw-theme="loopx"] .personal-goal-draft-status strong { color: #171717; }
.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-composer { border-radius: 6px; box-shadow: none; }
.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-composer:focus-within { border-color: #0070f3; box-shadow: 0 0 0 2px rgb(0 112 243 / 12%); }
-.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-composer > button,
+.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-composer .personal-composer-send,
.personal-workspace-shell[data-pw-theme="loopx"] .personal-correction-composer button { border-radius: 6px; background: #171717; box-shadow: none; }
-.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-composer > button:hover,
+.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-composer .personal-composer-send:hover,
.personal-workspace-shell[data-pw-theme="loopx"] .personal-correction-composer button:hover { background: #333333; }
-.personal-workspace-shell[data-pw-theme="loopx"] .personal-channel-composer > .personal-composer-attach { background: transparent; color: #6b6b6b; }
.personal-settings-theme-swatch.is-loopx { background: linear-gradient(135deg, #171717 0 48%, #fafafa 48% 78%, #ffffff 78%); }
.personal-settings-page[data-pw-theme="loopx"] .personal-settings-header small,
.personal-settings-page[data-pw-theme="loopx"] .personal-lark-header small,
@@ -1963,9 +1960,9 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-examples button { border-top: 1.5px solid #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer { border: 2px solid #141414; border-radius: 6px; box-shadow: 3px 3px 0 #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer:focus-within { border-color: #141414; box-shadow: 5px 5px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer textarea { border-left: 2px solid #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer > button, .personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer > .personal-composer-attach, .personal-workspace-shell[data-pw-theme="brutal"] .personal-correction-composer button { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 2px 2px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer > button:hover { background: #ffd91a; transform: translate(1px, 1px); box-shadow: 1px 1px 0 #141414; }
+.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer textarea { border: 0; }
+.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer .personal-composer-send, .personal-workspace-shell[data-pw-theme="brutal"] .personal-correction-composer button { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 2px 2px 0 #141414; }
+.personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer .personal-composer-send:hover { background: #ffd91a; transform: translate(1px, 1px); box-shadow: 1px 1px 0 #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-correction-composer { border: 2px solid #141414; border-radius: 4px; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-correction-composer:focus-within { border-color: #141414; }
@@ -2119,7 +2116,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-channel-header[data-goal-selected="true"] .personal-channel-actions { grid-row: 1; grid-column: 3; justify-self: end; }
.personal-channel-header[data-goal-selected="true"] .personal-goal-navigation { grid-row: 2; }
.personal-goal-navigation { gap: 8px; flex-wrap: wrap; justify-content: flex-start; }
- .personal-goal-navigation > .personal-goal-tabs { flex-basis: 100%; }
+ .personal-goal-navigation > .personal-goal-tabs { flex: 1 1 auto; }
.personal-goal-navigation > .personal-agent-select { flex: 1 1 110px; max-width: none; width: auto; }
.personal-goal-navigation .personal-chat-model-menu { flex: 1 1 120px; max-width: none; }
.personal-goal-navigation > .personal-agent-select .personal-select-value > small { display: none; }
@@ -2197,3 +2194,44 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-history-search input { flex: 1 1 120px; min-width: 0; width: 100%; }
.personal-history-search input, .personal-history-search button { min-height: 36px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); padding: 6px 8px; font: inherit; }
.personal-history-search button { cursor: pointer; }
+
+/* Send choices share one input frame; reading tabs retain the folded entry. */
+.personal-composer-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; }
+.personal-composer-toolbar > .personal-composer-tool-start { display: flex; align-items: center; flex: none; gap: 0; margin: 0; }
+.personal-composer-runtime { display: flex; align-items: center; justify-content: flex-end; flex: 1; gap: 4px; min-width: 0; margin-left: auto; }
+.personal-composer-runtime > .personal-composer-executor { flex: 0 1 auto; min-width: 0; max-width: 120px; }
+.personal-composer-runtime > span.personal-composer-executor { display: flex; align-items: center; gap: 5px; color: var(--pw-muted); overflow-wrap: anywhere; }
+.personal-composer-runtime .personal-select-trigger { min-height: 36px; padding: 4px 6px; border-color: transparent; background: transparent; box-shadow: none; }
+.personal-composer-runtime .personal-select-value { min-width: 0; }
+.personal-composer-runtime .personal-select-value > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.personal-composer-runtime .personal-select-listbox { top: auto; bottom: calc(100% + 8px); }
+.personal-composer-runtime .personal-chat-model-menu { flex: 0 1 auto; max-width: 260px; }
+.personal-composer-runtime .personal-chat-model-menu > summary { min-height: 36px; padding: 4px 6px; border-color: transparent; background: transparent; }
+.personal-composer-runtime .personal-chat-model-menu > summary > span { display: flex; align-items: baseline; gap: 6px; }
+.personal-composer-runtime .personal-chat-model-menu > summary small { flex: none; }
+.personal-composer-runtime .personal-chat-model-menu > summary .personal-model-current { font-size: var(--text-caption); }
+.personal-composer-runtime .personal-chat-model-panel { top: auto; bottom: calc(100% + 12px); }
+.personal-composer-runtime .personal-chat-model-panel .personal-select-listbox { bottom: auto; }
+.personal-composer-actions { position: relative; flex: none; margin: 0; }
+.personal-composer-actions > summary { display: grid; place-items: center; width: 36px; height: 36px; min-height: 36px; padding: 0; border-radius: 6px; color: var(--pw-muted); cursor: pointer; list-style: none; }
+.personal-composer-actions > summary::-webkit-details-marker { display: none; }
+.personal-composer-actions > summary:hover, .personal-composer-runtime .personal-select-trigger:hover, .personal-composer-runtime .personal-chat-model-menu > summary:hover { background: var(--pw-line); color: var(--pw-text); }
+.personal-composer-actions > summary:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; }
+.personal-composer-actions-panel { position: absolute; z-index: 50; bottom: calc(100% + 10px); left: 0; display: grid; gap: 8px; width: min(300px, calc(100vw - 44px)); max-height: min(420px, 65vh); overflow: auto; padding: 8px; border: 1px solid var(--pw-line); border-radius: 8px; background: var(--pw-card); box-shadow: 0 8px 24px rgb(0 0 0 / 9%); }
+.personal-composer-actions:not([open]) > .personal-composer-actions-panel { display: none; }
+.personal-composer-actions-panel button { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px; border: 0; border-radius: 4px; background: transparent; color: var(--pw-text); font: inherit; text-align: left; cursor: pointer; }
+.personal-composer-actions-panel button:hover { background: var(--pw-line); }
+.personal-composer-actions-panel button:disabled { opacity: .45; cursor: default; }
+.personal-composer-actions-panel .personal-quick-prompts, .personal-composer-actions-panel .personal-goal-suggestions { display: grid; gap: 0; margin: 0; padding: 0; border: 0; overflow: visible; }
+.personal-composer-actions-panel .personal-goal-suggestions { padding-bottom: 8px; margin-bottom: 8px; border-bottom: 1px solid var(--pw-line); }
+.personal-composer-toolbar .goal-loopx-message-mode { margin: 0; min-width: 0; }
+.personal-composer-toolbar .goal-loopx-message-mode select { max-width: 140px; min-height: 36px; border: 0; background: transparent; color: var(--pw-text); }
+.personal-composer-toolbar > .personal-composer-send { flex: none; margin-left: auto; }
+@media (max-width: 760px) {
+ .personal-composer-runtime .personal-chat-model-menu { position: static; max-width: min(140px, 36vw); }
+ .personal-composer-runtime .personal-chat-model-panel { left: 0; right: 0; width: auto; bottom: calc(100% + 8px); }
+ .personal-composer-runtime .personal-composer-executor { flex-shrink: 0; max-width: 100px; }
+ .personal-composer-runtime .personal-select-icon { display: none; }
+ .personal-composer-runtime .personal-select-trigger { gap: 3px; padding-inline: 4px; }
+ .personal-composer-toolbar > .goal-loopx-message-mode { flex: 1 0 calc(100% - 66px); }
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-theme.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/workspace-theme.test.mjs
index 15ac6c0baa..0a8d0b7b19 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-theme.test.mjs
+++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-theme.test.mjs
@@ -30,7 +30,7 @@ assert.ok(styles.includes('.personal-home-lanes { gap: 0; border-block: 1px soli
assert.ok(styles.includes('[data-pw-theme="loopx"] .personal-goal-mark { border: 1px solid #ebebeb; border-radius: 8px; background: #fff; color: #171717; }'), "LoopX standard removes decorative Goal tile colors");
assert.match(styles, /personal-home-goal-card { border-radius: 12px/, "LoopX standard uses the card radius for Goal cards");
assert.match(styles, /personal-channel-composer { border-radius: 6px/, "LoopX standard uses the control radius for the composer");
-assert.match(styles, /personal-channel-composer > button,[\s\S]*background: #171717/, "LoopX standard uses an ink command button");
+assert.match(styles, /personal-channel-composer \.personal-composer-send,[\s\S]*background: #171717/, "LoopX standard uses an ink command button");
assert.match(styles, /personal-quick-prompts button {[^}]*background: #fff/, "Shortcut actions keep one neutral chip style");
assert.match(styles, /personal-goal-draft-status { border-color: #dedede; border-radius: 6px; background: #f2f2f2/, "LoopX standard keeps Goal draft guidance neutral");
assert.ok(!styles.includes("personal-prompt-badge"), "Shortcut actions carry no draft/immediate badge");
@@ -55,8 +55,6 @@ assert.match(select, /event.key === "ArrowDown"/, "Workspace select supports arr
assert.match(select, /event.key === "Escape"/, "Workspace select supports keyboard dismissal");
assert.doesNotMatch(header, /