Skip to content

Commit 44ba20d

Browse files
feat(session): add app mode preference to session creation
- Add appMode field to session contracts (coding | work) - Add server-side validation for appMode in session operation - Wire appModePreference through create-application and main.tsx - Carry appMode into task-session-workflows at session creation - Add mode selection UI in settings with accessible radiogroup - Refactor AgentAvatar size mapping into a constant record - Auto-select first agent in management and reduce detail avatar to small - Enable independent scrolling for agent list and detail panels - Reduce usage popover skeleton rows from 8 to 4 for size stability
1 parent 0fb5e1c commit 44ba20d

17 files changed

Lines changed: 231 additions & 143 deletions

‎packages/webui/src/client/features/composer/ui/ComposerSubmissionControls.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function WebuiComposerModePills({
4141
{goalActive ? (
4242
<WebuiButton
4343
variant="accent-toggle"
44-
size="content"
44+
size="inline"
4545
aria-pressed={true}
4646
aria-label="取消目标模式"
4747
data-testid="composer-goal-mode"
@@ -55,7 +55,7 @@ export function WebuiComposerModePills({
5555
{planActive ? (
5656
<WebuiButton
5757
variant="accent-toggle"
58-
size="content"
58+
size="inline"
5959
type="button"
6060
aria-pressed="true"
6161
aria-label="退出计划模式"

‎packages/webui/src/client/features/composer/ui/WebuiComposer.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,11 @@ export function WebuiComposer({
950950
session: sessionId,
951951
lastSubmittedText: recordedTurn?.message,
952952
});
953+
// A pending Plan review rejects sends while it owns the session. Keep that
954+
// refusal in the stream state, but do not show a transcript error or retry
955+
// action for a request the user can send again after the review completes.
956+
const hidePlanReviewInProgressRefusal =
957+
stream.refusal === "Plan review processing must finish before this request can run";
953958
const handleRetryResend = () => {
954959
void composerController.retry({
955960
refusal: stream.refusal,
@@ -1037,7 +1042,7 @@ export function WebuiComposer({
10371042
paused={sessionId ? queuePaused : false}
10381043
onRemove={(item) => void handleDeleteQueueItem(item)}
10391044
/>
1040-
{stream.refusal ? (
1045+
{stream.refusal && !hidePlanReviewInProgressRefusal ? (
10411046
<OutputError
10421047
variant="output_error"
10431048
text={stream.refusal}

‎packages/webui/src/client/features/interactions/application/interaction-session-owners.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ import {
3030
import type { WebuiVersionCell } from "../../../application/version-cell.js";
3131

3232
export interface WebuiInteractionSessionOwners {
33-
/** The owner for one session, created on first touch and then stable. */
34-
readonly owner: (sessionId: string) => WebuiInteractionCoordinator;
33+
/** The owner for one state-record key, created on first touch and then stable. */
34+
readonly owner: (
35+
recordKey: string,
36+
options?: { readonly runtimeSessionId?: string },
37+
) => WebuiInteractionCoordinator;
3538
readonly sessions: () => readonly string[];
3639
/** Drop one session's owner and fence (purge / session delete). */
3740
readonly purge: (sessionId: string) => void;
@@ -73,14 +76,17 @@ export function createWebuiInteractionSessionOwners(deps: {
7376
let disposed = false;
7477

7578
return {
76-
owner: (sessionId) => {
77-
const existing = owners.get(sessionId);
79+
owner: (recordKey, options) => {
80+
const existing = owners.get(recordKey);
7881
if (existing) return existing;
7982
// The narrow writers: this session's interaction slice and its stream
8083
// phase, and nothing else in the record. Both come from the assembly, which
8184
// is what holds the store these slices live in.
82-
const commands = deps.commandsFor(sessionId);
83-
const streams = deps.readPhaseIntents(sessionId);
85+
const commands = deps.commandsFor(recordKey);
86+
const streams = deps.readPhaseIntents(recordKey);
87+
const runtimeSessionId = options === undefined
88+
? recordKey
89+
: options.runtimeSessionId;
8490
// The fence is this session's: a reply for this session cannot fence
8591
// another session's pending read, and the converse holds too.
8692
const pendingVersionRef: WebuiVersionCell = { current: 0 };
@@ -93,10 +99,10 @@ export function createWebuiInteractionSessionOwners(deps: {
9399
},
94100
streams,
95101
pendingVersionRef,
96-
sessionId,
97-
agentName: () => readAgentName?.(sessionId) ?? "main",
102+
sessionId: runtimeSessionId,
103+
agentName: () => readAgentName?.(recordKey) ?? "main",
98104
});
99-
owners.set(sessionId, owner);
105+
owners.set(recordKey, owner);
100106
return owner;
101107
},
102108
sessions: () => [...owners.keys()],

‎packages/webui/src/client/features/interactions/bindings/interaction-owner.public.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,7 @@ export function useWebuiInteractionOwner(
4646
[store],
4747
);
4848
const owners = application?.interactions ?? fallback;
49-
return owners.owner(webuiInteractionRecordKey(sessionId));
49+
return owners.owner(webuiInteractionRecordKey(sessionId), {
50+
runtimeSessionId: sessionId,
51+
});
5052
}

‎packages/webui/src/client/features/models/ui/ModelSelect.public.tsx‎

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
// with no second copy of the grouping, the ticks or the row order.
88
//
99
// It names a model as a string, because that is what the definitions and the
10-
// scheduled tasks store. `formatValue` decides how this call site writes it —
11-
// a provider-qualified `providerId/modelId` for a scheduled task, a bare model
12-
// id for an agent definition — and the same function reads the current value
13-
// back, so the two directions cannot drift. A value the catalogue does not
14-
// contain is still shown, verbatim: the runtime may offer a model this client
15-
// has not listed, and hiding the stored name would look like an empty field.
10+
// scheduled tasks store. The string is always `providerId/modelId`: that is the
11+
// only form the runtime accepts for a session model, a scheduled task's model
12+
// and an agent definition's model, and one writer for it means a form cannot
13+
// disagree with the picker it renders. A value the catalogue does not contain
14+
// is still shown, verbatim: the runtime may offer a model this client has not
15+
// listed, and hiding the stored name would look like an empty field.
1616
//
1717
// `defaultLabel` is the row that clears the choice, and it is what makes the
1818
// field optional. Without it the form has to be given a model, and the list has
@@ -29,25 +29,15 @@ import type { WebuiModelEntry } from "../../../../shared/contracts/models.js";
2929
import { groupModelsByProvider } from "../projection/model-picker-rows.js";
3030
import { WebuiModelListPanel } from "./ModelListPanel.js";
3131

32-
/** How a chosen model is written into the field, and read back out of it. */
33-
export type WebuiModelValueFormat = (model: WebuiModelEntry) => string;
34-
35-
/** The shape a scheduled task stores: `providerId/modelId`. */
36-
export function webuiQualifiedModelValue(model: WebuiModelEntry): string {
32+
/** The stored form of a model: `providerId/modelId`. */
33+
function qualifiedModelValue(model: WebuiModelEntry): string {
3734
return `${model.providerId}/${model.modelId}`;
3835
}
3936

40-
/** The shape an agent definition stores: the model's own id. */
41-
export function webuiBareModelValue(model: WebuiModelEntry): string {
42-
return model.modelId;
43-
}
44-
4537
export interface WebuiModelSelectProps extends Partial<FieldControlProps> {
4638
/** The stored model name, or `""` for "the default model". */
4739
readonly value: string;
4840
readonly onChange: (value: string) => void;
49-
/** Defaults to `providerId/modelId`. */
50-
readonly formatValue?: WebuiModelValueFormat;
5141
/** Shown when nothing is chosen. */
5242
readonly placeholder?: string;
5343
/** The row that clears the choice. Omitted, the choice is required. */
@@ -63,7 +53,6 @@ export interface WebuiModelSelectProps extends Partial<FieldControlProps> {
6353
export function WebuiModelSelect({
6454
value,
6555
onChange,
66-
formatValue = webuiQualifiedModelValue,
6756
placeholder = "选择模型",
6857
defaultLabel,
6958
controlSize = "regular",
@@ -90,9 +79,11 @@ export function WebuiModelSelect({
9079
const groups = useMemo(() => groupModelsByProvider(models), [models]);
9180
const selected = models.find(
9281
(model) =>
93-
formatValue(model) === value ||
94-
model.modelId === value ||
95-
webuiQualifiedModelValue(model) === value,
82+
qualifiedModelValue(model) === value ||
83+
// A catalogue row is still matched by its own id, so a stored bare name
84+
// or a foreign value from another client is shown rather than looking
85+
// like an empty field.
86+
model.modelId === value,
9687
);
9788
const triggerLabel = selected
9889
? selected.displayName ?? selected.modelId
@@ -149,7 +140,7 @@ export function WebuiModelSelect({
149140
groups={groups}
150141
selected={selected}
151142
onSelect={(model) => {
152-
onChange(formatValue(model));
143+
onChange(qualifiedModelValue(model));
153144
setOpen(false);
154145
}}
155146
{...(defaultLabel

‎packages/webui/src/client/features/plugins/ui/AgentForm.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
// Its four draft fields used to be four `useState` calls in `PluginCatalogue`,
44
// seeded by `beginAgent` there and handed to the dialog as values and setters.
55
// The draft now belongs to the form; what leaves it is one typed intent.
6+
//
7+
// The model field writes `providerId/modelId`, which is what the runtime's
8+
// canonical agent.md accepts: a bare model id is rejected as not being in the
9+
// provider/model form, so this field uses the picker's only format.
610

711
import { useState, type ReactElement } from "react";
812

@@ -15,7 +19,7 @@ import type { WebuiPluginIntent } from "../contracts/plugin-actions.js";
1519
import { WebuiButton } from "../../../ui/button.js";
1620
import { WebuiDialogActions } from "../../../ui/dialog.js";
1721
import { WebuiField, WebuiTextarea, WebuiTextInput, useWebuiFormValidation } from "../../../ui/field.js";
18-
import { WebuiModelSelect, webuiBareModelValue } from "../../models/ui/ModelSelect.public.js";
22+
import { WebuiModelSelect } from "../../models/ui/ModelSelect.public.js";
1923

2024
export interface AgentFormProps {
2125
readonly editing: Row | undefined;
@@ -97,7 +101,6 @@ export function AgentForm({
97101
{...props}
98102
value={model}
99103
onChange={setModel}
100-
formatValue={webuiBareModelValue}
101104
placeholder="使用默认模型"
102105
defaultLabel="使用默认模型"
103106
/>

‎packages/webui/src/client/features/settings/projection/settings-view.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
// The text below is the declaration as it stood in the source module — no
99
// behaviour change.
1010

11-
export type SettingsTabKey = "desktop" | "shortcuts" | "voice" | "custom-instructions" | "browser" | "usage" | "connection" | "account" | "coding" | "worktree" | "archived";
11+
export type SettingsTabKey = "desktop" | "shortcuts" | "custom-instructions" | "browser" | "usage" | "connection" | "account" | "coding" | "worktree" | "archived";
1212

1313
export interface SettingsTabDefinition { readonly key: SettingsTabKey; readonly group: "preferences" | "management" | "coding" | "archived"; readonly label: string; readonly icon: string; readonly disabled?: boolean; }
1414

15+
/** The sidebar's sections, in the order they are listed. */
16+
export type SettingsTabGroupKey = SettingsTabDefinition["group"];
17+
1518
export const DESKTOP_SETTINGS_TABS: readonly SettingsTabDefinition[] = [
16-
{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts" }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" },
19+
{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts" }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" },
1720
// The browser switches were under the browser panel, in the tool area. A
1821
// setting that lives in a panel is a setting nobody finds without opening
1922
// that panel first, so they are a page of their own — under 偏好, next to
@@ -31,3 +34,12 @@ export function resolveThemePreference(preference: string, systemDark: boolean):
3134
export function filterSettingsTabs(query: string): readonly SettingsTabDefinition[] { const needle = query.trim().toLowerCase(); return DESKTOP_SETTINGS_TABS.filter((tab) => !needle || `${tab.label} ${tab.key}`.toLowerCase().includes(needle)); }
3235

3336
export const SETTINGS_GROUPS = [{ key: "preferences", label: "偏好" }, { key: "management", label: "管理" }, { key: "coding", label: "编码" }, { key: "archived", label: "归档" }] as const;
37+
38+
/**
39+
* Groups the settings sidebar does not list.
40+
*
41+
* The tabs stay in `DESKTOP_SETTINGS_TABS` and their pages stay mounted behind
42+
* the dialog's `active` branch: this hides a section of the column, it does not
43+
* retire the feature. Listing a group again means deleting its key here.
44+
*/
45+
export const HIDDEN_SETTINGS_GROUPS: readonly SettingsTabGroupKey[] = ["coding"];

‎packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// unchanged, and every other settings surface stays in the private sibling.
1313

1414
import { Fragment, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type ReactElement, type ReactNode } from "react";
15-
import { IconArchive, IconArrowLeft, IconBraces, IconBrowser, IconChartBar, IconCode, IconGitBranch, IconLayoutDashboard, IconLink, IconMessageCircle, IconMicrophone, IconSearch, IconTrash, IconUserCircle, IconX, IconBook, IconCircle, IconCircleDot } from "@tabler/icons-react";
15+
import { IconArchive, IconArrowLeft, IconBraces, IconBrowser, IconChartBar, IconCode, IconGitBranch, IconLayoutDashboard, IconLink, IconMessageCircle, IconSearch, IconTrash, IconUserCircle, IconX, IconBook, IconCircle, IconCircleDot } from "@tabler/icons-react";
1616
import { WebuiSearchField, WebuiSelectMenu, WebuiTextInput } from "../../../ui/field.js";
1717
import { WebuiInline } from "../../../ui/layout-primitives.js";
1818
import type { WebuiSessionListItem } from "../../../../shared/contracts/session.js";
@@ -36,7 +36,7 @@ import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilit
3636
import { selectWebuiCatalogFlatPage } from "../../../features/sessions/application/session-catalog.public.js";
3737
import { BrowserSettingsPage } from "../../browser/ui/BrowserSettingsPage.public.js";
3838
import { ConnectionSettingsPage } from "./ConnectionSettingsPage.js";
39-
import { DESKTOP_SETTINGS_TABS, resolveThemePreference, filterSettingsTabs, SETTINGS_GROUPS } from "../projection/settings-view.js";
39+
import { DESKTOP_SETTINGS_TABS, resolveThemePreference, filterSettingsTabs, HIDDEN_SETTINGS_GROUPS, SETTINGS_GROUPS } from "../projection/settings-view.js";
4040
import type { SettingsTabKey } from "../projection/settings-view.js";
4141
import { DEFAULT_SHORTCUT_BINDINGS, parseShortcutBindings, serializeShortcutBindings, SHORTCUT_STORAGE_KEY } from "../../../projection/common/shortcuts.js";
4242
import { ShortcutsSettings } from "./ShortcutsSettings.js";
@@ -59,7 +59,6 @@ import { FeedbackEmpty } from "../../../ui/feedback.js";
5959
const ICONS = {
6060
desktop: IconLayoutDashboard,
6161
browser: IconBrowser,
62-
voice: IconMicrophone,
6362
shortcuts: IconBraces,
6463
"custom-instructions": IconBook,
6564
chart: IconChartBar,
@@ -75,9 +74,8 @@ const ICONS = {
7574
} as const;
7675

7776
function Icon({ name, size = 18 }: { readonly name: string; readonly size?: number }): ReactElement {
78-
const voice = name === "voice";
7977
const Glyph = ICONS[name as keyof typeof ICONS] ?? IconLayoutDashboard;
80-
return <Glyph aria-hidden="true" data-testid={voice ? "asr-mic-icon" : undefined} className="webui-settings-icon" size={size} stroke={1.4} />;
78+
return <Glyph aria-hidden="true" className="webui-settings-icon" size={size} stroke={1.4} />;
8179
}
8280
function stored(key: string, fallback: string, storage: ReturnType<typeof useWebuiBrowserCapabilities>["storage"]): string {
8381
return storage.getItem(key) ?? fallback;
@@ -235,7 +233,10 @@ export function SettingsModal({ open, onClose, initialTab, dataDir, version, ses
235233
if (!open) return null;
236234
const selected = models.find((model) => model.selected);
237235
const modelValue = selected ? `${selected.providerId}/${selected.modelId}/${selected.variant ?? ""}` : "";
238-
const groups = SETTINGS_GROUPS.map((group) => ({
236+
// A hidden group takes its tabs out of the column and out of the search
237+
// results both: the tabs come from `visibleTabs`, so a search hit inside a
238+
// hidden section would otherwise list a nav item the sidebar has dropped.
239+
const groups = SETTINGS_GROUPS.filter((group) => !HIDDEN_SETTINGS_GROUPS.includes(group.key)).map((group) => ({
239240
...group,
240241
tabs: visibleTabs.filter((tab) => tab.group === group.key),
241242
})).filter((group) => group.tabs.length > 0);

0 commit comments

Comments
 (0)