diff --git a/locales/en.json b/locales/en.json index 73bc12f40..0d40e4a14 100644 --- a/locales/en.json +++ b/locales/en.json @@ -459,6 +459,8 @@ "chat.runConfig.permissionLabel": "Permission", "chat.runConfig.reasoningLabel": "Reasoning", "chat.runConfig.recentLabel": "Recently used", + "chat.runConfig.roles.actions": "Role actions", + "chat.runConfig.roles.apply": "Apply role", "chat.runConfig.roles.create": "New role with settings", "chat.runConfig.roles.edit": "Edit role", "chat.runConfig.roles.label": "Role", @@ -466,6 +468,8 @@ "chat.runConfig.roles.optionOff": "Off", "chat.runConfig.roles.optionOn": "On", "chat.runConfig.roles.prompt": "Instruction", + "chat.runConfig.roles.send": "Send", + "chat.runConfig.roles.sendInstruction": "Send instruction", "chat.runConfig.title": "Run configuration", "chat.promptPlaceholder": "", "chat.projectPicker.clear": "Don't work in a project", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8eb5cc241..de435a17f 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -459,6 +459,8 @@ "chat.runConfig.permissionLabel": "权限", "chat.runConfig.reasoningLabel": "推理", "chat.runConfig.recentLabel": "最近使用", + "chat.runConfig.roles.actions": "角色操作", + "chat.runConfig.roles.apply": "使用角色", "chat.runConfig.roles.create": "使用当前配置新建角色", "chat.runConfig.roles.edit": "编辑角色", "chat.runConfig.roles.label": "角色", @@ -466,6 +468,8 @@ "chat.runConfig.roles.optionOff": "关", "chat.runConfig.roles.optionOn": "开", "chat.runConfig.roles.prompt": "预设指令", + "chat.runConfig.roles.send": "发送", + "chat.runConfig.roles.sendInstruction": "直接发送指令", "chat.runConfig.title": "运行设置", "chat.promptPlaceholder": "", "chat.projectPicker.clear": "不在项目中工作", diff --git a/packages/components/src/components/mobile/AGENTS.md b/packages/components/src/components/mobile/AGENTS.md index b546d2c28..2097b625b 100644 --- a/packages/components/src/components/mobile/AGENTS.md +++ b/packages/components/src/components/mobile/AGENTS.md @@ -216,9 +216,14 @@ embedded` lazy-imported from `../tasks/tasks-workspace.tsx` (`embedded` renders even when there is nothing to list, reading `None`. It sits above Agent, since a Role answers every row under it, and is an ordinary inline picker: `None` first, then the Roles by emoji + name, an unavailable one - listed but disabled with its reason, and `New role` last. Mobile has no detail - pane and no edit: a phone row cannot carry the binding a Role authorizes, so - that is read on desktop or in Settings. `None` reports `null`, which clears + listed but disabled for short-tap Apply with its reason, and `New role` last. + Mobile has no detail pane and no edit: a phone row cannot carry the binding a + Role authorizes, so that is read on desktop or in Settings. In an existing + Session, a 500ms long press replaces the list with Apply Role / Send + Instruction actions. Apply keeps the availability gate; Send remains enabled + for an unavailable Role with an instruction because it sends a Role-less Turn + and preserves the composer draft. New-chat omits that long-press action. + `None` reports `null`, which clears the NAME and leaves the configuration as it stands. `None` also carries an EMPTY glyph so its label lines up with the emoji-led rows under it; the trigger deliberately does not, because it shows one value rather than a diff --git a/packages/components/src/components/mobile/mobile-inline-picker.tsx b/packages/components/src/components/mobile/mobile-inline-picker.tsx index 89d0dce49..3e9812dbc 100644 --- a/packages/components/src/components/mobile/mobile-inline-picker.tsx +++ b/packages/components/src/components/mobile/mobile-inline-picker.tsx @@ -16,6 +16,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { Check, Loader2, Search, X } from 'lucide-react'; import { filterFuzzyOptions } from '@/lib/fuzzy-option-filter'; +import { useLongPress } from '@/hooks/use-long-press'; import { cn } from '@/lib/utils'; // Above this many (filtered) options the dropdown list is virtualized — big branch @@ -35,6 +36,8 @@ export type MobileInlinePickerOption = { disabled?: boolean; /** Tooltip / aria title for disabled options. */ disabledReason?: string; + /** Optional touch action. A completed hold suppresses the following tap. */ + onLongPress?: () => void; }; export type MobileInlinePickerProps = { @@ -196,6 +199,11 @@ export function MobileInlinePicker({ opens it without moving focus); ↑/↓ move this index, Enter/Space select it, Esc closes. -1 = nothing highlighted (closed). */ const [activeIndex, setActiveIndex] = useState(-1); + const longPressOptionRef = useRef | null>(null); + const { handlers: longPressHandlers, shouldSwallowClick } = useLongPress({ + enabled: options.some((option) => option.onLongPress != null), + onLongPress: () => longPressOptionRef.current?.onLongPress?.(), + }); /* Auto-focus the search input on open only with a precise pointer (desktop) — never on touch, where it would force the soft keyboard up. */ const autoFocusSearch = useMemo( @@ -407,15 +415,35 @@ export function MobileInlinePicker({ type="button" id={optionId(index)} data-active={isActive} - disabled={opt.disabled} + /* A disabled option with a long-press action must still receive touch + events. It remains aria-disabled and its regular tap is ignored. */ + disabled={opt.disabled && !opt.onLongPress} + aria-disabled={opt.disabled || undefined} title={opt.disabled ? opt.disabledReason : undefined} - onClick={() => { + onClick={(event) => { + if (opt.onLongPress && shouldSwallowClick()) { + event.preventDefault(); + longPressOptionRef.current = null; + return; + } if (opt.disabled) return; handleSelect(opt.value); }} - onPointerMove={() => { + onPointerDown={(event) => { + if (!opt.onLongPress || event.button !== 0) return; + longPressOptionRef.current = opt; + longPressHandlers.onPointerDown?.(event); + }} + onPointerMove={(event) => { + if (opt.onLongPress) longPressHandlers.onPointerMove?.(event); if (!opt.disabled && !isActive) setActiveIndex(index); }} + onPointerUp={(event) => longPressHandlers.onPointerUp?.(event)} + onPointerCancel={(event) => longPressHandlers.onPointerCancel?.(event)} + onPointerLeave={(event) => longPressHandlers.onPointerLeave?.(event)} + onContextMenu={(event) => { + if (opt.onLongPress) event.preventDefault(); + }} className={cn( 'flex w-full select-none items-center gap-2 px-3 py-2 text-left text-sm transition-colors', 'hover:bg-hover/60 focus-visible:outline-none focus-visible:bg-hover/60', diff --git a/packages/components/src/components/mobile/mobile-run-config-sheet.tsx b/packages/components/src/components/mobile/mobile-run-config-sheet.tsx index b85874010..22994210f 100644 --- a/packages/components/src/components/mobile/mobile-run-config-sheet.tsx +++ b/packages/components/src/components/mobile/mobile-run-config-sheet.tsx @@ -1,6 +1,6 @@ -import { useMemo, type ReactNode } from 'react'; +import { useMemo, useState, type ReactNode } from 'react'; import { useAtomValue } from 'jotai'; -import { ListChecks, Plus, ShieldAlert, Zap } from 'lucide-react'; +import { Check, ListChecks, Plus, Send, ShieldAlert, Zap } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { getAllAgentConfigAtom } from '@/atoms'; @@ -37,13 +37,16 @@ import { Switch } from '@/ui/switch'; import { classifyPermissionModeFace, getAgentRoleEmoji, + type AgentRole, type AgentRoleId, type MachineId, } from '@lody/shared'; import { MobileInlinePicker, MobileInlinePickerCoordinator, + MobileInlineMenu, MobileInlinePickerRowSlot, + useMobileInlinePickerCoordinator, type MobileInlinePickerOption, } from './mobile-inline-picker'; @@ -102,6 +105,8 @@ export type MobileRunConfigSheetProps = { onSelect: (roleId: AgentRoleId | null) => void; /** Opens the Role editor seeded with what the composer is set to right now. */ onCreate?: () => void; + /** Existing Sessions may send only a Role's instruction as a new Turn. */ + onSendInstruction?: (role: AgentRole) => Promise; }; }; @@ -140,7 +145,10 @@ export function MobileRunConfigSheet({ style={keyboard.scrollStyle} > - + onOpenChange(false)} + /> @@ -167,8 +175,11 @@ function permissionModeIcon(modeId: string | null): ReactNode { with the same entry. */ const ROLE_NONE_VALUE = '__none__'; const ROLE_CREATE_VALUE = '__create__'; +const ROLE_ACTIONS_MENU_ID = 'run-config-role-actions'; -type MobileRunConfigSheetRowsProps = Omit; +type MobileRunConfigSheetRowsProps = Omit & { + onRequestClose: () => void; +}; function MobileRunConfigSheetRows({ agentSelection, @@ -185,9 +196,12 @@ function MobileRunConfigSheetRows({ configOptionValues, onConfigOptionChange, agentRoles, + onRequestClose, }: MobileRunConfigSheetRowsProps) { const { t } = useTranslation(); const executorConfigs = useAtomValue(getAllAgentConfigAtom); + const pickerCoordinator = useMobileInlinePickerCoordinator(); + const [actionRoleId, setActionRoleId] = useState(null); const { modelSelectors, @@ -245,6 +259,14 @@ function MobileRunConfigSheetRows({ ), disabled: reason !== null, + ...(agentRoles.onSendInstruction && role.promptPrefix?.trim() + ? { + onLongPress: () => { + setActionRoleId(role.id); + pickerCoordinator?.requestActive(ROLE_ACTIONS_MENU_ID); + }, + } + : {}), ...(reason ? { description: reason, disabledReason: reason } : {}), }; }), @@ -259,10 +281,13 @@ function MobileRunConfigSheetRows({ ] : []), ]; - }, [agentRoles, roleNoneLabel, t]); + }, [agentRoles, pickerCoordinator, roleNoneLabel, t]); const selectedRole = agentRoles?.selectedRoleId ? agentRoles.items.find((item) => item.role.id === agentRoles.selectedRoleId)?.role : undefined; + const actionRoleItem = actionRoleId + ? agentRoles?.items.find((item) => item.role.id === actionRoleId) + : undefined; /* ── Agent (options scoped by allowedMachineIds when provided) ── */ const agentOptions = useMemo[]>(() => { @@ -448,34 +473,83 @@ function MobileRunConfigSheetRows({ first one, which is what the desktop row does too. */} {agentRoles ? ( - - id="run-config-role" - value={agentRoles.selectedRoleId ?? ROLE_NONE_VALUE} - onChange={(value) => { - if (value === ROLE_CREATE_VALUE) { - agentRoles.onCreate?.(); - return; + <> + + id="run-config-role" + value={agentRoles.selectedRoleId ?? ROLE_NONE_VALUE} + onChange={(value) => { + if (value === ROLE_CREATE_VALUE) { + agentRoles.onCreate?.(); + return; + } + agentRoles.onSelect(value === ROLE_NONE_VALUE ? null : (value as AgentRoleId)); + }} + options={roleOptions} + ariaLabel={roleRowLabel} + searchable={shouldOfferOptionSearch(roleOptions.length)} + triggerContent={ + <> + {/* No reserved slot here: the trigger is one value, not a list, + so `None` reads better flush against the row than indented + past an empty box. The OPTIONS keep the slot, because there + the labels are read as a column. */} + {selectedRole ? ( + + ) : null} + {selectedRole?.name ?? roleNoneLabel} + } - agentRoles.onSelect(value === ROLE_NONE_VALUE ? null : (value as AgentRoleId)); - }} - options={roleOptions} - ariaLabel={roleRowLabel} - searchable={shouldOfferOptionSearch(roleOptions.length)} - triggerContent={ - <> - {/* No reserved slot here: the trigger is one value, not a list, - so `None` reads better flush against the row than indented - past an empty box. The OPTIONS keep the slot, because there - the labels are read as a column. */} - {selectedRole ? ( - - ) : null} - {selectedRole?.name ?? roleNoneLabel} - - } - /> + /> + {actionRoleItem && agentRoles.onSendInstruction ? ( + + {({ close }) => ( + <> +
+ + {actionRoleItem.role.name} +
+ + {actionRoleItem.role.promptPrefix?.trim() ? ( + + ) : null} + + )} +
+ ) : null} +
) : null} diff --git a/packages/components/src/components/mobile/mobile-session-run-config.tsx b/packages/components/src/components/mobile/mobile-session-run-config.tsx index 873abb1fe..ae85c0c1b 100644 --- a/packages/components/src/components/mobile/mobile-session-run-config.tsx +++ b/packages/components/src/components/mobile/mobile-session-run-config.tsx @@ -10,7 +10,7 @@ import type { } from '@/components/shared/acp-selector-options'; import type { AcpSessionSelectOption } from '@/components/shared/acp-session-select'; import type { AgentSelection } from '@/components/shared/agent-selector'; -import type { AgentConfigCliType, AgentRoleId, MachineId } from '@lody/shared'; +import type { AgentConfigCliType, AgentRole, AgentRoleId, MachineId } from '@lody/shared'; import type { ComposerAgentRoleItem } from '@/lib/composer-agent-roles'; import { MobileRunConfigButton } from './mobile-run-config-button'; import { MobileRunConfigSheet } from './mobile-run-config-sheet'; @@ -58,6 +58,7 @@ export type MobileSessionRunConfigProps = { selectedRoleId: AgentRoleId | null; onSelect: (roleId: AgentRoleId | null) => void; onCreate?: () => void; + onSendInstruction?: (role: AgentRole) => Promise; }; }; diff --git a/packages/components/src/components/sessions/AGENTS.md b/packages/components/src/components/sessions/AGENTS.md index afbb74dc1..59252e54c 100644 --- a/packages/components/src/components/sessions/AGENTS.md +++ b/packages/components/src/components/sessions/AGENTS.md @@ -381,8 +381,15 @@ Session conversation page chain: RUN CONFIG, which is exactly what transfers: model / reasoning / permission are the values a session can still change every turn. Keep the Role's real availability so a stale binding stays visible but cannot be selected. The - Role's INSTRUCTION is not applied, because a prompt prefix belongs to the - first turn of a session the Role creates. The row is NOT gated on + Role's INSTRUCTION is not applied by selection, because a prompt prefix + belongs to the first turn of a session the Role creates. The persisted-Session + detail pane may explicitly send that instruction as a standalone shortcut: + it dispatches through the existing Session's normal direct/guide/queue route + with explicit Role None, never creates or navigates to another Session, and + bypasses composer submission so text, mentions, pasted drafts, pending uploads, + images, and files remain untouched. Its edit and send actions stay in the pane + header so the instruction keeps the full readable body height. Chat Landing and uncreated child-tab drafts + do not receive that action. The row is NOT gated on `isEmptyConversation`: those values stay changeable for the whole conversation. An unsent explicit selection (including None) lives in session-keyed app state rather than the composer component: top-level @@ -493,7 +500,11 @@ Session conversation page chain: Mobile (`MobileSessionRunConfig` → `MobileRunConfigSheet`) has the same Role row, in the same place — above Agent — as an ordinary `MobileInlinePicker`, with no detail pane and no edit: a phone row cannot carry the binding a Role - authorizes, so the binding is read on desktop or in Settings. It DOES offer + authorizes, so the binding is read on desktop or in Settings. Short tap keeps + applying a Role. In a persisted Session only, long press opens Apply Role / + Send Instruction actions; Apply retains the Role's availability gate, while + Send is available whenever an instruction exists because it is a Role-less + Turn, not an attempt to run that Role. It DOES offer create, as the last entry in the list. The row renders whenever the caller passes `agentRoles`, even with none to list — the row then reads `None` and its list is the way to make the first one, which is what the desktop row does diff --git a/packages/components/src/components/sessions/agent-role-detail-pane.tsx b/packages/components/src/components/sessions/agent-role-detail-pane.tsx index e90d710ab..a54aadf84 100644 --- a/packages/components/src/components/sessions/agent-role-detail-pane.tsx +++ b/packages/components/src/components/sessions/agent-role-detail-pane.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { - ArrowRight, Brain, Cpu, + Pencil, + Send, ShieldAlert, ShieldCheck, Sliders, @@ -65,13 +66,20 @@ export function AgentRoleDetailPane({ machine, machineLabel, onEdit, + onSendInstruction, className, }: AgentRoleDetailSubject & { onEdit?: (roleId: AgentRoleId) => void; + /** Existing Sessions may send the instruction without applying this Role. */ + onSendInstruction?: (role: AgentRole) => Promise; /** The host's box: each menu sizes its own pane to the list beside it. */ className?: string; }) { const { t } = useTranslation(); + const editLabel = t('chat.runConfig.roles.edit', 'Edit role'); + const sendInstructionLabel = t('chat.runConfig.roles.sendInstruction', 'Send instruction'); + const canSendInstruction = Boolean(onSendInstruction && role.promptPrefix?.trim()); + const hasActions = Boolean(onEdit || canSendInstruction); const selectorOptions = useAcpSelectorOptions( agentConfig ? { @@ -143,7 +151,11 @@ export function AgentRoleDetailPane({ return (
+ {hasActions ? ( + + {onEdit ? ( + + ) : null} + {canSendInstruction ? ( + + ) : null} + + ) : null}
- {/* Only the values scroll. The header says WHICH Role and the footer is - how to change it — both stay put however long the instruction runs. */} + {/* Only the values scroll. The header says WHICH Role and owns its actions, + so both stay put however long the instruction runs. */}
{modelId ? ( @@ -244,19 +283,6 @@ export function AgentRoleDetailPane({
) : null}
- - {onEdit ? ( -
- -
- ) : null} ); } diff --git a/packages/components/src/components/sessions/composer-agent-role-panel.tsx b/packages/components/src/components/sessions/composer-agent-role-panel.tsx index d459264f7..8c6633efe 100644 --- a/packages/components/src/components/sessions/composer-agent-role-panel.tsx +++ b/packages/components/src/components/sessions/composer-agent-role-panel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Ban, Check, Plus } from 'lucide-react'; import { getAgentRoleEmoji, + type AgentRole, type AgentRoleAvailability, type AgentRoleId, type MachineViewMeta, @@ -32,6 +33,7 @@ export function ComposerAgentRolePanel({ onSelect, onCreate, onEdit, + onSendInstruction, }: { items: readonly ComposerAgentRoleItem[]; /** @@ -46,6 +48,7 @@ export function ComposerAgentRolePanel({ onSelect: (roleId: AgentRoleId | null) => void; onCreate?: () => void; onEdit?: (roleId: AgentRoleId) => void; + onSendInstruction?: (role: AgentRole) => Promise; }) { const { t } = useTranslation(); const [previewRoleId, setPreviewRoleId] = useState(null); @@ -123,6 +126,7 @@ export function ComposerAgentRolePanel({ agentConfig={previewItem.agentConfig} machine={machine} onEdit={onEdit} + onSendInstruction={onSendInstruction} /> ); diff --git a/packages/components/src/components/sessions/desktop-run-config-menu.tsx b/packages/components/src/components/sessions/desktop-run-config-menu.tsx index 9ebdc0966..28fc45d90 100644 --- a/packages/components/src/components/sessions/desktop-run-config-menu.tsx +++ b/packages/components/src/components/sessions/desktop-run-config-menu.tsx @@ -389,6 +389,8 @@ export type DesktopRunConfigMenuProps = { /** Opens the Role editor seeded with what the composer is set to right now. */ onCreate?: () => void; onEdit?: (roleId: AgentRoleId) => void; + /** Existing Sessions may send a Role instruction without applying the Role. */ + onSendInstruction?: (role: AgentRole) => Promise; /** The machine those Roles are bound to, for resolving their stored ids. */ machine?: MachineViewMeta | null; }; @@ -731,6 +733,7 @@ export function DesktopRunConfigMenu({ onSelect={agentRoles.onSelect} onCreate={agentRoles.onCreate} onEdit={agentRoles.onEdit} + onSendInstruction={agentRoles.onSendInstruction} /> diff --git a/packages/components/src/components/sessions/session-chat-input-area.tsx b/packages/components/src/components/sessions/session-chat-input-area.tsx index 6bfce70d3..52d41298a 100644 --- a/packages/components/src/components/sessions/session-chat-input-area.tsx +++ b/packages/components/src/components/sessions/session-chat-input-area.tsx @@ -439,6 +439,8 @@ export interface SessionChatInputAreaProps { inputBlocks: SessionInputBlock[], agentRole: SessionTurnAgentRoleSelection ) => Promise; + /** Send only a Role's instruction as a new Turn, preserving this composer draft. */ + onSendAgentRoleInstruction?: (role: AgentRole) => Promise; onStop: () => void | Promise; onRemoveQueueItem: (itemId: string) => Promise; /** When provided and conversation is empty, the agent config badge becomes a selector. */ @@ -524,6 +526,7 @@ export const SessionChatInputArea = memo( onModelChange, onConfigOptionChange, onSendMessage, + onSendAgentRoleInstruction, onStop, onRemoveQueueItem: _onRemoveQueueItem, onAgentConfigChange, @@ -2269,6 +2272,7 @@ export const SessionChatInputArea = memo( items: effectiveAgentRoleControl.items, selectedRoleId: effectiveAgentRoleControl.selectedRoleId, onSelect: effectiveAgentRoleControl.onSelect, + onSendInstruction: onSendAgentRoleInstruction, onCreate: () => setAgentRoleEditor( openAgentRoleEditorForCreate( @@ -2290,6 +2294,7 @@ export const SessionChatInputArea = memo( session.agentConfigId, session.machineId, effectiveAgentRoleControl, + onSendAgentRoleInstruction, ] ); const selectedAgentRolePinsPermissionMode = useMemo(() => { diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 7029fcbb4..2f1ddd59e 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -63,6 +63,7 @@ import { useTranslation } from 'react-i18next'; import { useRouter } from '@tanstack/react-router'; import { toast } from 'sonner'; import type { + AgentRole, LocalProjectId, MessageContent, MessageQueueItemInput, @@ -3967,6 +3968,19 @@ export const SessionChatInterface = memo( [dispatchInputBlocks] ); + const handleSendAgentRoleInstruction = useCallback( + async (role: AgentRole): Promise => { + if (!role.promptPrefix?.trim()) return false; + /* This is deliberately not a composer submission: the only block is + the shortcut instruction, so the draft and attachments stay put. + Explicit None also prevents inheriting or applying any Role. */ + return await dispatchInputBlocks([{ type: 'text', text: role.promptPrefix }], { + agentRole: null, + }); + }, + [dispatchInputBlocks] + ); + const capacityRetry = useCapacityAutoRetry({ sessionId: session.id, history: sessionDoc?.history, @@ -6041,6 +6055,7 @@ export const SessionChatInterface = memo( onModelChange={handleModelChange} onConfigOptionChange={handleConfigOptionChange} onSendMessage={handleSendMessage} + onSendAgentRoleInstruction={handleSendAgentRoleInstruction} onStop={() => { void handleStop(); }} diff --git a/packages/components/src/stories/ComposerRunConfigMenu.stories.tsx b/packages/components/src/stories/ComposerRunConfigMenu.stories.tsx index 3ea87d6be..186420a4c 100644 --- a/packages/components/src/stories/ComposerRunConfigMenu.stories.tsx +++ b/packages/components/src/stories/ComposerRunConfigMenu.stories.tsx @@ -222,11 +222,14 @@ function StoryShell({ items, initialRoleId = null, models = modelOptions, + canSendInstruction = false, }: { items: ReadonlyArray; initialRoleId?: AgentRoleId | null; /** Overridden by the long-list story: what an agent provider may publish. */ models?: AcpSessionSelectOption[]; + /** Existing Sessions can send a Role's instruction without applying it. */ + canSendInstruction?: boolean; }) { const store = useMemo(() => { const s = createStore(); @@ -321,6 +324,7 @@ function StoryShell({ }, onCreate: fn(), onEdit: fn(), + onSendInstruction: canSendInstruction ? async () => true : undefined, }} /> {/* Mirrors the composer footer: behind a Role that pins permission, @@ -381,6 +385,14 @@ export const RoleSubmenu: Story = { }, }; +/** An existing Session can send the highlighted Role's instruction without applying it. */ +export const RoleInstructionShortcut: Story = { + args: { canSendInstruction: true }, + play: async ({ canvasElement }) => { + await openRoleSubmenu(canvasElement); + }, +}; + /** * A Role the composer currently IS: the footer names it and states its values * beside the button, and the permission button is gone because the Role pins it. diff --git a/packages/components/src/stories/MobileRunConfigSheet.stories.tsx b/packages/components/src/stories/MobileRunConfigSheet.stories.tsx index ed2ca565a..e0ef39722 100644 --- a/packages/components/src/stories/MobileRunConfigSheet.stories.tsx +++ b/packages/components/src/stories/MobileRunConfigSheet.stories.tsx @@ -237,6 +237,7 @@ function StoryShell({ selectedRoleId: roleId, onSelect: setRoleId, onCreate: fn(), + onSendInstruction: async () => true, } : undefined } @@ -337,8 +338,9 @@ export const NoAgentRolesYet: Story = { /** * The Role row sits above Agent, because a Role answers every row under it. - * Mobile is the picker only: no detail pane, no create action. `None` leads the - * list, and an unavailable Role stays listed and disabled with its reason. + * Mobile is the picker only: no detail pane. `None` leads the list, and an + * unavailable Role stays listed and disabled with its reason. Long-press a Role + * to see the existing-Session Apply / Send Instruction actions. */ export const WithAgentRoles: Story = { args: { @@ -347,15 +349,30 @@ export const WithAgentRoles: Story = { selectors: codexSelectors, agentRoles: [ { - role: makeRole({ id: 'role-reviewer' as AgentRoleId, name: 'Code Reviewer', emoji: '🔍' }), + role: makeRole({ + id: 'role-reviewer' as AgentRoleId, + name: 'Code Reviewer', + emoji: '🔍', + promptPrefix: 'Review the current change for correctness before style.', + }), availability: { kind: 'available' }, }, { - role: makeRole({ id: 'role-docs' as AgentRoleId, name: 'Docs Writer', emoji: '📝' }), + role: makeRole({ + id: 'role-docs' as AgentRoleId, + name: 'Docs Writer', + emoji: '📝', + promptPrefix: 'Update the documentation for the current change.', + }), availability: { kind: 'available' }, }, { - role: makeRole({ id: 'role-gone' as AgentRoleId, name: 'Retired Reviewer', emoji: '🗑️' }), + role: makeRole({ + id: 'role-gone' as AgentRoleId, + name: 'Retired Reviewer', + emoji: '🗑️', + promptPrefix: 'Review the current change without applying this retired Role.', + }), availability: { kind: 'unavailable', reason: 'agent_config_missing' }, }, ], diff --git a/packages/components/tests/agent-role-detail-pane.test.tsx b/packages/components/tests/agent-role-detail-pane.test.tsx index 32b789412..f16dbb7dd 100644 --- a/packages/components/tests/agent-role-detail-pane.test.tsx +++ b/packages/components/tests/agent-role-detail-pane.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, createElement, type ComponentProps } from 'react'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { AGENT_ROLE_VERSION, @@ -122,9 +122,28 @@ describe('AgentRoleDetailPane', () => { edited = roleId; }, }); + const edit = editable.querySelector('button[aria-label="Edit role"]'); + expect(edit?.closest('header')).not.toBeNull(); await act(async () => { - editable.querySelector('button')?.click(); + edit?.click(); }); expect(edited).toBe('role-1'); }); + + it('sends the shown instruction only when the host is an existing Session', async () => { + const onSendInstruction = vi.fn(async () => true); + const instructionRole = role({ promptPrefix: 'Correctness before style.' }); + const view = await render({ role: instructionRole, onSendInstruction }); + const send = view.querySelector('button[aria-label="Send instruction"]'); + expect(send?.closest('header')).not.toBeNull(); + + await act(async () => { + send?.click(); + }); + + expect(onSendInstruction).toHaveBeenCalledWith(instructionRole); + + const noInstruction = await render({ role: role(), onSendInstruction }); + expect(noInstruction.querySelector('button[aria-label="Send instruction"]')).toBeNull(); + }); }); diff --git a/packages/components/tests/composer-agent-role-panel.test.tsx b/packages/components/tests/composer-agent-role-panel.test.tsx index 8074d9f77..a0947db37 100644 --- a/packages/components/tests/composer-agent-role-panel.test.tsx +++ b/packages/components/tests/composer-agent-role-panel.test.tsx @@ -182,15 +182,27 @@ describe('ComposerAgentRolePanel', () => { it('offers editing the Role whose configuration it is showing', async () => { const onEdit = vi.fn(); const view = await render({ onEdit }); - const edit = [...view.querySelectorAll('button')].find((node) => - node.textContent?.includes('Edit role') - ); + const edit = view.querySelector('button[aria-label="Edit role"]'); await act(async () => { - (edit as HTMLElement).click(); + edit?.click(); }); expect(onEdit).toHaveBeenCalledWith('r-1'); }); + it('sends the instruction without selecting the previewed Role', async () => { + const onSelect = vi.fn(); + const onSendInstruction = vi.fn(async () => true); + const view = await render({ onSelect, onSendInstruction }); + const send = view.querySelector('button[aria-label="Send instruction"]'); + + await act(async () => { + send?.click(); + }); + + expect(onSendInstruction).toHaveBeenCalledWith(reviewer.role); + expect(onSelect).not.toHaveBeenCalled(); + }); + it('keeps an unavailable Role listed, disabled, and says why', async () => { const onSelect = vi.fn(); const view = await render({ diff --git a/packages/components/tests/mobile-run-config-role-row.test.tsx b/packages/components/tests/mobile-run-config-role-row.test.tsx index b95c6f24b..1e4435691 100644 --- a/packages/components/tests/mobile-run-config-role-row.test.tsx +++ b/packages/components/tests/mobile-run-config-role-row.test.tsx @@ -25,6 +25,9 @@ import { initI18n } from '../src/i18n'; // The picker's virtualizer scrolls the active row into view; jsdom has no // layout and therefore no `scrollIntoView`. Element.prototype.scrollIntoView = () => undefined; +Element.prototype.setPointerCapture = () => undefined; +Element.prototype.releasePointerCapture = () => undefined; +Element.prototype.hasPointerCapture = () => false; const machineId = 'machine-1' as MachineId; const agentConfig: AgentConfigMeta = { @@ -51,12 +54,21 @@ const makeRole = (overrides: Partial & Pick }); const reviewer: ComposerAgentRoleItem = { - role: makeRole({ id: 'role-1' as AgentRoleId, name: 'Code Reviewer', emoji: '🔍' }), + role: makeRole({ + id: 'role-1' as AgentRoleId, + name: 'Code Reviewer', + emoji: '🔍', + promptPrefix: 'Review this carefully.', + }), availability: { kind: 'available' }, agentConfig, }; const retired: ComposerAgentRoleItem = { - role: makeRole({ id: 'role-2' as AgentRoleId, name: 'Retired Reviewer' }), + role: makeRole({ + id: 'role-2' as AgentRoleId, + name: 'Retired Reviewer', + promptPrefix: 'Review this without applying the retired role.', + }), availability: { kind: 'unavailable', reason: 'agent_config_missing' }, }; @@ -91,6 +103,7 @@ describe('MobileRunConfigSheet agent-role row', () => { }); afterEach(async () => { + vi.useRealTimers(); if (root) { await act(async () => { root?.unmount(); @@ -236,6 +249,80 @@ describe('MobileRunConfigSheet agent-role row', () => { expect(onSelect).toHaveBeenCalledWith('role-1'); }); + it('keeps short tap as Apply when instruction actions are available', async () => { + const onSelect = vi.fn(); + const onSendInstruction = vi.fn(async () => true); + const view = await render({ + agentRoles: { + items: [reviewer], + selectedRoleId: null, + onSelect, + onSendInstruction, + }, + }); + await openRolePicker(view); + const option = [...view.querySelectorAll('[role="dialog"] button')].find((node) => + node.textContent?.includes('Code Reviewer') + ); + await act(async () => { + (option as HTMLElement).click(); + }); + expect(onSelect).toHaveBeenCalledWith('role-1'); + expect(onSendInstruction).not.toHaveBeenCalled(); + }); + + it('long-presses an unavailable Role to send its instruction without applying it', async () => { + const onSelect = vi.fn(); + const onSendInstruction = vi.fn(async () => true); + const view = await render({ + agentRoles: { + items: [retired], + selectedRoleId: null, + onSelect, + onSendInstruction, + }, + }); + await openRolePicker(view); + const option = [...view.querySelectorAll('[role="dialog"] button')].find((node) => + node.textContent?.includes('Retired Reviewer') + ); + expect(option?.getAttribute('aria-disabled')).toBe('true'); + await act(async () => { + (option as HTMLElement).click(); + }); + expect(onSelect).not.toHaveBeenCalled(); + + vi.useFakeTimers(); + await act(async () => { + option?.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + button: 0, + clientX: 10, + clientY: 10, + }) + ); + vi.advanceTimersByTime(500); + }); + + const actions = view.querySelector('[role="menu"][aria-label="Role actions"]'); + const apply = [...(actions?.querySelectorAll('button') ?? [])].find((node) => + node.textContent?.includes('Apply role') + ); + const send = [...(actions?.querySelectorAll('button') ?? [])].find((node) => + node.textContent?.includes('Send instruction') + ); + expect((apply as HTMLButtonElement).disabled).toBe(true); + + await act(async () => { + send?.click(); + await Promise.resolve(); + }); + + expect(onSendInstruction).toHaveBeenCalledWith(retired.role); + expect(onSelect).not.toHaveBeenCalled(); + }); + it('keeps an unavailable Role listed, disabled, and says why', async () => { const onSelect = vi.fn(); const view = await render({ diff --git a/packages/components/tests/session-chat-input-submission.test.tsx b/packages/components/tests/session-chat-input-submission.test.tsx index 6f53733d3..dabb81de7 100644 --- a/packages/components/tests/session-chat-input-submission.test.tsx +++ b/packages/components/tests/session-chat-input-submission.test.tsx @@ -17,6 +17,14 @@ const sessionAgentRoleState = vi.hoisted(() => ({ }, })); +const desktopRunConfigState = vi.hoisted(() => ({ + agentRoles: undefined as + | { + onSendInstruction?: (role: AgentRole) => Promise; + } + | undefined, +})); + vi.mock('@posthog/react', () => ({ usePostHog: () => null })); vi.mock('../src/components/mentions/mention-session-source', async (importOriginal) => ({ @@ -65,7 +73,12 @@ vi.mock('../src/components/sessions/desktop-run-config-menu', async () => { return { DesktopPermissionModeButton: () => React.createElement('div', { 'data-testid': 'desktop-permission-mode-button' }), - DesktopRunConfigMenu: () => null, + DesktopRunConfigMenu: (props: { + agentRoles?: { onSendInstruction?: (role: AgentRole) => Promise }; + }) => { + desktopRunConfigState.agentRoles = props.agentRoles; + return null; + }, }; }); vi.mock('../src/hooks/use-session-agent-role', () => ({ @@ -113,6 +126,7 @@ describe('SessionChatInputArea submission feedback', () => { selectedRoleId: null, onSelect: () => undefined, }; + desktopRunConfigState.agentRoles = undefined; await initI18n('en'); Object.defineProperty(window, 'matchMedia', { configurable: true, @@ -202,6 +216,75 @@ describe('SessionChatInputArea submission feedback', () => { ).not.toBeNull(); }); + it('keeps the composer draft when a Role instruction is sent separately', async () => { + const instructionRole = { + v: 1, + id: 'role-instruction' as AgentRoleId, + revision: 1, + name: 'Reviewer', + visibility: 'private', + ownerUserId: 'user-1', + machineId: 'machine-1', + agentConfigId: 'agent-1', + promptPrefix: 'Review only the current change.', + runConfig: {}, + createdAt: 1, + updatedAt: 1, + } as AgentRole; + sessionAgentRoleState.control = { + items: [{ role: instructionRole, availability: { kind: 'available' } }], + selectedRoleId: null, + onSelect: () => undefined, + }; + const onSendAgentRoleInstruction = vi.fn(async () => true); + const onSendMessage = vi.fn(async () => true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + createElement(SessionChatInputArea, { + session: { + id: 'session-role-instruction', + userId: 'user-1', + machineId: 'machine-1', + agentConfigId: 'agent-1', + cliType: 'builtin', + agentType: 'codex', + status: { type: 'idle' }, + isArchived: false, + createdAt: '2026-09-03T00:00:00.000Z', + } as SessionMeta, + sessionLocalProjectRootPath: null, + isMachineRemoved: false, + isAgentBusy: false, + isDark: false, + isEmptyConversation: false, + selectedModeId: null, + selectedModelId: null, + modeOptions: [], + modelOptions: [], + onModeChange: () => undefined, + onModelChange: () => undefined, + onSendMessage, + onSendAgentRoleInstruction, + onStop: () => undefined, + onRemoveQueueItem: async () => undefined, + initialInputText: 'Keep this draft exactly as-is.', + }) + ); + }); + + await act(async () => { + await desktopRunConfigState.agentRoles?.onSendInstruction?.(instructionRole); + }); + + expect(onSendAgentRoleInstruction).toHaveBeenCalledWith(instructionRole); + expect(onSendMessage).not.toHaveBeenCalled(); + expect(container.querySelector('textarea')?.value).toBe('Keep this draft exactly as-is.'); + }); + it('does not submit against transient run-config defaults while the Session doc hydrates', async () => { const onSendMessage = vi.fn(async () => true); container = document.createElement('div');