Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,17 @@
"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",
"chat.runConfig.roles.none": "None",
"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",
Expand Down
4 changes: 4 additions & 0 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,17 @@
"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": "角色",
"chat.runConfig.roles.none": "无",
"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": "不在项目中工作",
Expand Down
11 changes: 8 additions & 3 deletions packages/components/src/components/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions packages/components/src/components/mobile/mobile-inline-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +36,8 @@ export type MobileInlinePickerOption<T extends string = string> = {
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<T extends string = string> = {
Expand Down Expand Up @@ -196,6 +199,11 @@ export function MobileInlinePicker<T extends string = string>({
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<MobileInlinePickerOption<T> | 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(
Expand Down Expand Up @@ -407,15 +415,35 @@ export function MobileInlinePicker<T extends string = string>({
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',
Expand Down
138 changes: 106 additions & 32 deletions packages/components/src/components/mobile/mobile-run-config-sheet.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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<boolean>;
};
};

Expand Down Expand Up @@ -140,7 +145,10 @@ export function MobileRunConfigSheet({
style={keyboard.scrollStyle}
>
<MobileInlinePickerCoordinator>
<MobileRunConfigSheetRows {...contentProps} />
<MobileRunConfigSheetRows
{...contentProps}
onRequestClose={() => onOpenChange(false)}
/>
</MobileInlinePickerCoordinator>
</div>
</DrawerContent>
Expand All @@ -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<MobileRunConfigSheetProps, 'open' | 'onOpenChange'>;
type MobileRunConfigSheetRowsProps = Omit<MobileRunConfigSheetProps, 'open' | 'onOpenChange'> & {
onRequestClose: () => void;
};

function MobileRunConfigSheetRows({
agentSelection,
Expand All @@ -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<AgentRoleId | null>(null);

const {
modelSelectors,
Expand Down Expand Up @@ -245,6 +259,14 @@ function MobileRunConfigSheetRows({
</span>
),
disabled: reason !== null,
...(agentRoles.onSendInstruction && role.promptPrefix?.trim()
? {
onLongPress: () => {
setActionRoleId(role.id);
pickerCoordinator?.requestActive(ROLE_ACTIONS_MENU_ID);
},
}
: {}),
...(reason ? { description: reason, disabledReason: reason } : {}),
};
}),
Expand All @@ -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<MobileInlinePickerOption<string>[]>(() => {
Expand Down Expand Up @@ -448,34 +473,83 @@ function MobileRunConfigSheetRows({
first one, which is what the desktop row does too. */}
{agentRoles ? (
<RunConfigRow label={roleRowLabel}>
<MobileInlinePicker<string>
id="run-config-role"
value={agentRoles.selectedRoleId ?? ROLE_NONE_VALUE}
onChange={(value) => {
if (value === ROLE_CREATE_VALUE) {
agentRoles.onCreate?.();
return;
<>
<MobileInlinePicker<string>
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 ? (
<span className="text-base leading-none" aria-hidden="true">
{getAgentRoleEmoji(selectedRole)}
</span>
) : null}
<span className="truncate">{selectedRole?.name ?? roleNoneLabel}</span>
</>
}
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 ? (
<span className="text-base leading-none" aria-hidden="true">
{getAgentRoleEmoji(selectedRole)}
</span>
) : null}
<span className="truncate">{selectedRole?.name ?? roleNoneLabel}</span>
</>
}
/>
/>
{actionRoleItem && agentRoles.onSendInstruction ? (
<MobileInlineMenu
id={ROLE_ACTIONS_MENU_ID}
triggerContent={null}
ariaLabel={t('chat.runConfig.roles.actions', 'Role actions')}
triggerClassName="hidden"
>
{({ close }) => (
<>
<div className="flex items-center gap-2 px-3 py-2 text-sm font-medium">
<span className="text-base leading-none" aria-hidden="true">
{getAgentRoleEmoji(actionRoleItem.role)}
</span>
<span className="truncate">{actionRoleItem.role.name}</span>
</div>
<button
type="button"
role="menuitem"
disabled={actionRoleItem.availability.kind !== 'available'}
onClick={() => {
close();
agentRoles.onSelect(actionRoleItem.role.id);
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors active:bg-hover/60 disabled:cursor-not-allowed disabled:opacity-50"
>
<Check className="h-4 w-4 shrink-0" aria-hidden="true" />
{t('chat.runConfig.roles.apply', 'Apply role')}
</button>
{actionRoleItem.role.promptPrefix?.trim() ? (
<button
type="button"
role="menuitem"
onClick={() => {
close();
onRequestClose();
void agentRoles.onSendInstruction?.(actionRoleItem.role);
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors active:bg-hover/60"
>
<Send className="h-4 w-4 shrink-0" aria-hidden="true" />
{t('chat.runConfig.roles.sendInstruction', 'Send instruction')}
</button>
) : null}
</>
)}
</MobileInlineMenu>
) : null}
</>
</RunConfigRow>
) : null}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,6 +58,7 @@ export type MobileSessionRunConfigProps = {
selectedRoleId: AgentRoleId | null;
onSelect: (roleId: AgentRoleId | null) => void;
onCreate?: () => void;
onSendInstruction?: (role: AgentRole) => Promise<boolean>;
};
};

Expand Down
17 changes: 14 additions & 3 deletions packages/components/src/components/sessions/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading