You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/10/16 04:29:08 UTC
(maka) branch main updated: fix(ui): improve sidebar menus, project task actions, and execution location display (#6088)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 168b62161e fix(ui): improve sidebar menus, project task actions, and execution location display (#6088)
168b62161e is described below
commit 168b62161e6b140a856810d39217d8ff6c365941
Author: YayoiNanoka <ya...@qq.com>
AuthorDate: Fri Oct 16 12:29:04 2026 +0800
fix(ui): improve sidebar menus, project task actions, and execution location display (#6088)
This PR fixes two sidebar bugs and makes two usability improvements to project rows:
1. Clear session highlighting after closing the context menu. Right-clicking a session could leave it highlighted even after clicking elsewhere. The menu's temporary highlight now clears when the menu is dismissed.
2. Show project menus on right-click. Right-clicking a project row previously did nothing. It now opens the same action menu as the row's `⋯` button.
Generated-by: OpenAI Codex
---
.../session-navigation-controller.test.ts | 2 +-
.../model/session-navigation-groups.ts | 21 +-
apps/desktop/src/renderer/styles/sidebar.css | 48 +++-
.../session-history-multi-select.test.tsx | 287 ++++++++++++++++++---
.../__tests__/session-history-row-actions.test.tsx | 34 +++
packages/ui/src/icons.tsx | 1 +
packages/ui/src/session-history-list.tsx | 124 ++++++---
packages/ui/src/session-hover-card-copy.ts | 16 ++
packages/ui/src/session-rail-context.tsx | 5 +-
packages/ui/stories/session-list-panel.stories.tsx | 33 ++-
10 files changed, 463 insertions(+), 108 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts
index 03f947d3ff..ba3b36f374 100644
--- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts
@@ -316,7 +316,7 @@ describe('useSessionNavigationController', () => {
);
assert.deepEqual(
controller().selectors.groups.map(({ label }) => label),
- ['Project · Local', 'Project · Remote Mac', 'Project · Ubuntu'],
+ ['Project', 'Project', 'Project'],
);
assert.equal(
controller().selectors.sessionMeta(linkedCatalog[2]!),
diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts
index 9cb6740313..2c5607e8ac 100644
--- a/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts
@@ -38,10 +38,6 @@ export function ungroupedGroupId(hostId: string): string {
return `${UNGROUPED_KEY}:${hostId}`;
}
-function scopedProjectLabel(projectName: string, profileName: string): string {
- return `${projectName} · ${profileName}`;
-}
-
/** Groups every Session by its owning Runtime Host and Project at one level. */
export function deriveSessionNavigationGroups(
sessions: readonly SessionNavigationSession[],
@@ -82,7 +78,8 @@ export function deriveSessionNavigationGroups(
const key = scope.key;
return {
id: projectGroupId(key),
- label: scopedProjectLabel(scope.project.name, scope.profileName),
+ label: scope.project.name,
+ host: { kind: scope.profileKind, name: scope.profileName },
sessions: sessionsByProject.get(key) ?? [],
// The UI treats this id as an opaque action target. Scope it here so
// equal Host-local UUIDs can never route a mutation to another Host.
@@ -100,22 +97,18 @@ export function deriveSessionNavigationGroups(
.at(-1);
groups.push({
id: projectGroupId(key),
- label: scopedProjectLabel(
- pathName || first.projectId || 'Project',
- first.profileName,
- ),
+ label: pathName || first.projectId || 'Project',
+ host: { kind: first.profileKind, name: first.profileName },
sessions: missingSessions,
});
}
for (const [hostId, ungrouped] of ungroupedByHost) {
- const profileName = ungrouped[0]!.profileName;
+ const first = ungrouped[0]!;
groups.push({
id: ungroupedGroupId(hostId),
- label: scopedProjectLabel(
- getShellRemainingCopy(locale).projects.ungrouped,
- profileName,
- ),
+ label: getShellRemainingCopy(locale).projects.ungrouped,
+ host: { kind: first.profileKind, name: first.profileName },
sessions: ungrouped,
});
}
diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css
index c34b731b34..791198e764 100644
--- a/apps/desktop/src/renderer/styles/sidebar.css
+++ b/apps/desktop/src/renderer/styles/sidebar.css
@@ -230,6 +230,10 @@
-webkit-app-region: no-drag;
}
+.maka-project-row[data-host-kind="remote"] > div > .astryx-side-nav-item .lucide-globe {
+ color: var(--color-text-blue);
+}
+
/*
* A project row receiving a dragged task. Drawn on the row's own box rather
* than on the nested SideNavItem so the whole target — the heading and any
@@ -412,6 +416,10 @@
min-height: var(--h-control-sm, 24px);
}
+.maka-session-row-trailing[data-new-task="true"] {
+ width: calc(2 * var(--h-control-sm, 24px) + var(--space-2));
+}
+
/*
* The signal slot: the row's state at rest, or its timestamp when it has no
* state; the ⋯ menu on hover or keyboard focus.
@@ -490,7 +498,8 @@
* sibling slot instead of non-interactive endContent. Menus occupy a reserved
* trailing slot visually, but stay siblings in the DOM so neither control
* contains the other. */
-.maka-session-row-action {
+.maka-session-row-action,
+.maka-project-row-actions {
position: absolute;
inset-block-start: calc(
(var(--size-element-md) - var(--size-element-sm)) / 2
@@ -500,6 +509,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
+}
+
+.maka-session-row-action {
width: var(--h-control-sm, 24px);
}
@@ -507,14 +519,22 @@
inset-inline-end: calc(var(--space-2) - (var(--h-control-sm, 24px) - var(--_ring-box)) / 2);
}
-/* A collapsible project keeps Astryx's 24px disclosure after endContent. Match
- * the placeholder's original slot immediately before that disclosure: row
- * gutter + disclosure + SideNavItem gap. Empty projects have no disclosure and
- * use the default trailing inset above. */
-.maka-project-row
+.maka-project-row-actions {
+ gap: var(--space-2);
+}
+
+.maka-project-row-actions > .maka-session-row-action {
+ position: static;
+}
+
+/* Astryx's whole-row collapse control has a decorative trailing chevron but
+ * no hide-chevron prop. Keep its collapse behavior and aria-expanded while
+ * replacing that decoration with the project's sibling action controls. */
+.maka-project-row:has(> div > .maka-project-row-actions)
> div
- > .maka-session-row-action[data-position="before-disclosure"] {
- inset-inline-end: calc(var(--space-2) + var(--space-6) + var(--space-2));
+ > .astryx-side-nav-item[aria-expanded]
+ > .astryx-icon:last-child {
+ display: none;
}
/* The sibling menu is visually part of the project header, but Astryx's
@@ -522,14 +542,14 @@
* feedback only while the direct project action is targeted; a plain wrapper
* :hover selector would also light the header over nested session rows. */
@media (hover: hover) {
- .maka-project-row:has(> div > .maka-session-row-action:hover)
+ .maka-project-row:has(> div > .maka-project-row-actions:hover)
> div
> .astryx-side-nav-item {
background-color: var(--color-overlay-hover);
}
}
-.maka-project-row:has(> div > .maka-session-row-action button:active)
+.maka-project-row:has(> div > .maka-project-row-actions button:active)
> div
> .astryx-side-nav-item {
background-color: var(--color-overlay-pressed);
@@ -585,8 +605,11 @@
* `isSelected` itself is spent on the open row alone: it renders
* `aria-current="page"`, and a set of picked rows is not a set of current
* pages. What a screen reader hears instead is in the row's description.
+ * A menu target shares this ground only while its menu is open; opening a
+ * menu does not make that row part of the persistent selection.
*/
-.maka-session-row[data-picked='true'] .astryx-side-nav-item {
+.maka-session-row[data-picked='true'] .astryx-side-nav-item,
+.maka-session-row:has(> .maka-session-row-action[data-menu-open='true']) .astryx-side-nav-item {
background-color: var(--color-neutral);
}
@@ -598,7 +621,8 @@
* ground — the two states stay one paint in both worlds.
*/
@media (forced-colors: active) {
- .maka-session-row[data-picked='true'] .astryx-side-nav-item {
+ .maka-session-row[data-picked='true'] .astryx-side-nav-item,
+ .maka-session-row:has(> .maka-session-row-action[data-menu-open='true']) .astryx-side-nav-item {
background-color: Highlight;
color: HighlightText;
}
diff --git a/packages/ui/src/__tests__/session-history-multi-select.test.tsx b/packages/ui/src/__tests__/session-history-multi-select.test.tsx
index c3d0b000f1..202099a588 100644
--- a/packages/ui/src/__tests__/session-history-multi-select.test.tsx
+++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx
@@ -30,6 +30,7 @@ import test from 'node:test';
import { act, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
+import type { ProjectRecord } from '@maka/core/project';
import type { SessionSummary } from '@maka/core/session';
import { LocaleProvider } from '../locale-context.js';
import { SessionHistoryList } from '../session-history-list.js';
@@ -116,6 +117,17 @@ const ROW_ACTIONS = {
onRename: () => undefined,
};
+const PROJECT_ACTIONS = {
+ onNew: () => undefined,
+ onRename: () => undefined,
+ onArchive: () => undefined,
+ onRestore: () => undefined,
+};
+
+function projectRecord(id: string): ProjectRecord {
+ return { id, name: id, available: true, locations: [] };
+}
+
type PickCall = Parameters<SessionRailSelectionCommands['pick']>[0];
interface Harness {
@@ -127,10 +139,12 @@ interface Harness {
clears: number;
archives: number;
document: Document;
+ click(element: Element): Promise<void>;
clickRow(sessionId: string, modifiers?: Record<string, unknown>): Promise<void>;
beginRightClickRow(sessionId: string): Promise<boolean>;
endRightClickRow(sessionId: string): Promise<void>;
rightClickRow(sessionId: string): Promise<boolean>;
+ rightClickProject(projectId: string): Promise<boolean>;
openRowMenu(sessionId: string): Promise<void>;
clickMenuItem(index: number): Promise<void>;
menuLabels(): string[];
@@ -148,6 +162,9 @@ async function mount(
selectedIds?: readonly string[];
sessions?: SessionSummary[];
activeId?: string;
+ rowActions?: SessionRailData['rowActions'];
+ projectActions?: SessionRailData['projectActions'];
+ selectionEnabled?: boolean;
groupVariant?: SessionRailData['groupVariant'];
groups?: SessionRailData['groups'];
} = {},
@@ -171,20 +188,19 @@ async function mount(
let clears = 0;
let archives = 0;
// `pick` only records, because what the app's reducer makes of a click is
- // that reducer's own test. `retain` also applies, because the menu's wording
- // and its sweep are read from the set it narrows — a stub that recorded and
- // left the set alone would let an assertion about the menu pass over rows
- // the narrowing was supposed to have removed.
- let narrow: Dispatch<SetStateAction<ReadonlySet<string>>> | undefined;
+ // that reducer's own test. `retain` and `clear` also apply, because menu
+ // wording and row highlights must reflect the set those commands leave.
+ let updateSelection: Dispatch<SetStateAction<ReadonlySet<string>>> | undefined;
const commands: SessionRailSelectionCommands = {
pick: (request) => picks.push(request),
clear: () => {
clears += 1;
+ updateSelection?.(new Set());
},
retain: (sessionIds) => {
retains.push([...sessionIds]);
const keep = new Set(sessionIds);
- narrow?.((current) => new Set([...current].filter((sessionId) => keep.has(sessionId))));
+ updateSelection?.((current) => new Set([...current].filter((sessionId) => keep.has(sessionId))));
},
archiveSelected: () => {
archives += 1;
@@ -198,12 +214,12 @@ async function mount(
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
() => new Set(options.selectedIds ?? []),
);
- narrow = setSelectedIds;
+ updateSelection = setSelectedIds;
const selection = useMemo<SessionRailSelection>(() => ({ selectedIds, commands }), [
selectedIds,
]);
return (
- <SessionRailProvider data={data} selection={selection}>
+ <SessionRailProvider data={data} selection={options.selectionEnabled === false ? undefined : selection}>
<SessionHistoryList />
</SessionRailProvider>
);
@@ -214,7 +230,8 @@ async function mount(
groupVariant: options.groupVariant ?? 'conversation',
groups: options.groups ?? [{ id: 'recent', label: 'Recent', sessions: [...sessions] }],
onSelectSession: (sessionId) => opened.push(sessionId),
- rowActions: ROW_ACTIONS,
+ rowActions: options.rowActions ?? ROW_ACTIONS,
+ projectActions: options.projectActions,
};
const container = document.querySelector('#root');
@@ -260,6 +277,11 @@ async function mount(
return archives;
},
document: document as unknown as Document,
+ click: async (element) => {
+ await act(async () => {
+ element.dispatchEvent(pointerEvent(window, 'click'));
+ });
+ },
clickRow: async (sessionId, modifiers = {}) => {
await act(() => {
rowButton(sessionId).dispatchEvent(pointerEvent(window, 'click', modifiers));
@@ -282,14 +304,16 @@ async function mount(
});
},
rightClickRow: async (sessionId) => {
- const event = pointerEvent(window, 'contextmenu');
- await act(async () => {
- rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerdown'));
- rowButton(sessionId).dispatchEvent(event);
- rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerup'));
- await new Promise((resolve) => window.setTimeout(resolve, 0));
- });
- return event.defaultPrevented;
+ const prevented = await rightClick(rowButton(sessionId));
+ if (prevented) openedMenuRow = sessionId;
+ return prevented;
+ },
+ rightClickProject: async (projectId) => {
+ const header = document.querySelector(
+ `[data-project-id="${projectId}"] > div > button.astryx-side-nav-item`,
+ );
+ assert.ok(header, `no header for project ${projectId}`);
+ return rightClick(header);
},
openRowMenu: async (sessionId) => {
const trigger = document.querySelector(
@@ -313,7 +337,7 @@ async function mount(
clickMenuItem: async (index) => {
const item = menuItems()[index];
assert.ok(item, `no menu item at ${index}`);
- await act(() => {
+ await act(async () => {
item.dispatchEvent(pointerEvent(window, 'click'));
});
},
@@ -356,6 +380,17 @@ async function mount(
},
};
+ async function rightClick(element: Element): Promise<boolean> {
+ const event = pointerEvent(window, 'contextmenu');
+ await act(async () => {
+ element.dispatchEvent(pointerEvent(window, 'pointerdown'));
+ element.dispatchEvent(event);
+ element.dispatchEvent(pointerEvent(window, 'pointerup'));
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ return event.defaultPrevented;
+ }
+
async function pressEscapeWith(focused: Element): Promise<boolean> {
Object.defineProperty(document, 'activeElement', {
configurable: true,
@@ -559,19 +594,51 @@ test('right-clicking a pickable row opens its menu', async () => {
}
});
-test('right-clicking a row outside the set replaces it', async () => {
- // A menu is about a set, and one opened on a row the user never picked must
- // not silently be about the rows they did.
- const harness = await mount({ selectedIds: ['a', 'b'] });
- try {
- await harness.rightClickRow('d');
- assert.equal(harness.picks[0]?.pick, 'replace');
- assert.equal(harness.picks[0]?.sessionId, 'd');
- assert.deepEqual(harness.opened, []);
- } finally {
- await harness.dispose();
+for (const openMenu of ['rightClickRow', 'openRowMenu'] as const) {
+ for (const selectedIds of [[], ['a', 'b']]) {
+ const initialSelection = selectedIds.length ? 'a picked set' : 'an empty set';
+ test(`${openMenu} outside ${initialSelection} keeps its target out of selection`, async () => {
+ const archived: string[] = [];
+ const harness = await mount({
+ selectedIds,
+ activeId: 'a',
+ rowActions: {
+ ...ROW_ACTIONS,
+ onArchive: (sessionId) => { archived.push(sessionId); },
+ },
+ });
+ try {
+ await harness[openMenu]('d');
+ assert.equal(
+ harness.document.querySelector('[data-session-id="d"] .maka-session-row-action')
+ ?.getAttribute('data-menu-open'),
+ 'true',
+ );
+ assert.deepEqual(harness.picks, [], 'opening a menu must not create a lasting pick');
+ assert.ok(harness.clears > 0);
+ assert.equal(harness.document.querySelector('[data-picked="true"]'), null);
+ assert.deepEqual(harness.opened, []);
+ assert.equal(
+ harness.document.querySelector('[data-session-id="a"] button.astryx-side-nav-item')
+ ?.getAttribute('aria-current'),
+ 'page',
+ );
+ assert.deepEqual(harness.menuLabels(), ['Pin', 'Rename', 'Archive']);
+ await harness.clickMenuItem(2);
+ assert.deepEqual(archived, ['d'], 'the single-row action still owns its menu target');
+ assert.equal(harness.archives, 0, 'the old picked set must not be swept');
+ assert.equal(
+ harness.document.querySelector('[data-session-id="d"] .maka-session-row-action')
+ ?.getAttribute('data-menu-open'),
+ null,
+ );
+ assert.equal(harness.document.querySelector('[data-picked="true"]'), null);
+ } finally {
+ await harness.dispose();
+ }
+ });
}
-});
+}
test('right-clicking inside the set keeps it', async () => {
// A run built by dragging has to survive the gesture that asks what to do
@@ -580,24 +647,15 @@ test('right-clicking inside the set keeps it', async () => {
try {
await harness.rightClickRow('b');
assert.deepEqual(harness.picks, []);
+ assert.equal(harness.clears, 0);
+ assert.equal(harness.document.querySelectorAll('[data-picked="true"]').length, 2);
+ assert.deepEqual(harness.menuLabels(), ['Pin 2 tasks', 'Archive 2 tasks']);
assert.deepEqual(harness.opened, []);
} finally {
await harness.dispose();
}
});
-test('clicking ⋯ on a row outside the set replaces it, and never opens the task', async () => {
- const harness = await mount({ selectedIds: ['a'] });
- try {
- await harness.openRowMenu('d');
- assert.deepEqual(harness.opened, []);
- assert.equal(harness.picks[0]?.pick, 'replace');
- assert.equal(harness.picks[0]?.sessionId, 'd');
- } finally {
- await harness.dispose();
- }
-});
-
test('the menu counts the set once more than one row is picked', async () => {
const harness = await mount({ selectedIds: ['a', 'b', 'c'] });
try {
@@ -605,6 +663,8 @@ test('the menu counts the set once more than one row is picked', async () => {
assert.deepEqual(harness.menuLabels(), ['Pin 3 tasks', 'Archive 3 tasks']);
await harness.clickMenuItem(1);
assert.equal(harness.archives, 1);
+ assert.equal(harness.clears, 0);
+ assert.equal(harness.document.querySelectorAll('[data-picked="true"]').length, 3);
} finally {
await harness.dispose();
}
@@ -652,6 +712,149 @@ test('a mixed set pins, including the rows already pinned', async () => {
}
});
+for (const selectionEnabled of [true, false]) {
+ test(`a project context menu preserves collapse and selection (selection enabled: ${selectionEnabled})`, async () => {
+ const sessions = [summary('a'), summary('b')];
+ const harness = await mount({
+ sessions,
+ selectedIds: ['a', 'b'],
+ selectionEnabled,
+ groupVariant: 'project',
+ groups: [{ id: 'p', label: 'p', project: projectRecord('p'), sessions }],
+ projectActions: PROJECT_ACTIONS,
+ });
+ try {
+ await harness.collapseProject('p');
+ assert.equal(await harness.rightClickProject('p'), true);
+ const header = harness.document.querySelector('[data-project-id="p"] > div > button');
+ assert.equal(header?.getAttribute('aria-expanded'), 'false');
+ const menu = harness.document.querySelector('.maka-project-row-actions .maka-session-row-action');
+ assert.ok(menu);
+ const trigger = menu.querySelector('button[aria-haspopup="menu"]');
+ assert.equal(trigger?.getAttribute('aria-expanded'), 'true');
+ const menuId = trigger?.getAttribute('aria-controls');
+ assert.ok(menuId);
+ const menuContent = harness.document.getElementById(menuId);
+ assert.ok(menuContent);
+ assert.deepEqual(
+ [...menuContent.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent?.trim()),
+ ['New task', 'Rename', 'Archive'],
+ );
+ assert.deepEqual(harness.picks, []);
+ assert.deepEqual(harness.retains, []);
+ assert.equal(harness.clears, 0);
+ assert.deepEqual(harness.opened, []);
+ assert.equal(
+ harness.document.querySelectorAll('[data-picked="true"]').length,
+ selectionEnabled ? 2 : 0,
+ );
+ } finally {
+ await harness.dispose();
+ }
+ });
+}
+
+for (const shared of [false, true]) {
+ test(`right-clicking a nested ${shared ? 'shared' : 'actionable'} task does not open its project menu`, async () => {
+ const sessions = [shared ? { ...summary('a'), shared: true } as SessionSummary : summary('a')];
+ const harness = await mount({
+ sessions,
+ selectedIds: shared ? [] : ['a'],
+ groupVariant: 'project',
+ groups: [{ id: 'p', label: 'p', project: projectRecord('p'), sessions }],
+ projectActions: PROJECT_ACTIONS,
+ });
+ try {
+ assert.equal(await harness.rightClickRow('a'), !shared);
+ const projectMenuTrigger = harness.document.querySelector('button[aria-label="p project actions"]');
+ assert.equal(projectMenuTrigger?.getAttribute('aria-expanded'), 'false');
+ assert.equal(
+ harness.document.querySelector('[data-session-id="a"] .maka-session-row-action')
+ ?.getAttribute('data-menu-open') ?? null,
+ shared ? null : 'true',
+ );
+ assert.equal(
+ harness.document.querySelector('[data-project-id="p"] > div > button')
+ ?.getAttribute('aria-expanded'),
+ 'true',
+ );
+ } finally {
+ await harness.dispose();
+ }
+ });
+}
+
+for (const project of [undefined, projectRecord('p')]) {
+ test(`a ${project ? 'project without actions' : 'group without a project'} keeps its native context menu`, async () => {
+ const sessions = [summary('a')];
+ const harness = await mount({
+ sessions,
+ groupVariant: 'project',
+ groups: [{ id: 'p', label: 'p', project, sessions }],
+ projectActions: project ? undefined : PROJECT_ACTIONS,
+ });
+ try {
+ assert.equal(await harness.rightClickProject('p'), false);
+ assert.equal(harness.document.querySelector('[data-menu-open="true"]'), null);
+ assert.deepEqual(harness.picks, []);
+ assert.equal(harness.clears, 0);
+ } finally {
+ await harness.dispose();
+ }
+ });
+}
+
+test('creating a project task uses its project id, preserves collapse and shares the pending guard', async () => {
+ const sessions = [summary('a')];
+ const created: string[] = [];
+ let finishCreation!: () => void;
+ const pending = new Promise<void>((resolve) => { finishCreation = resolve; });
+ const harness = await mount({
+ sessions,
+ selectedIds: ['a'],
+ groupVariant: 'project',
+ groups: [{ id: 'host:project', label: 'p', project: projectRecord('p'), sessions }],
+ projectActions: {
+ ...PROJECT_ACTIONS,
+ onNew: (projectId) => {
+ created.push(projectId);
+ return pending;
+ },
+ },
+ });
+ try {
+ await harness.collapseProject('host:project');
+ const header = harness.document.querySelector('[data-project-id="host:project"] > div > button');
+ const create = harness.document.querySelector('.maka-project-row-actions button[aria-label="p New task"]');
+ const menuTrigger = harness.document.querySelector('.maka-project-row-actions button[aria-haspopup="menu"]');
+ assert.ok(create);
+ assert.ok(menuTrigger);
+ await harness.click(create);
+ await harness.click(create);
+ assert.deepEqual(created, ['p'], 'the action receives the project id, not its group key');
+ assert.equal(header?.getAttribute('aria-expanded'), 'false');
+ assert.equal(create.getAttribute('aria-disabled'), 'true');
+ assert.equal(menuTrigger.getAttribute('aria-disabled'), 'true');
+ assert.equal(harness.clears, 0);
+ assert.deepEqual(harness.picks, []);
+
+ await act(async () => { finishCreation(); });
+ assert.equal(create.getAttribute('aria-disabled'), null);
+ assert.equal(menuTrigger.getAttribute('aria-disabled'), null);
+ assert.equal(await harness.rightClickProject('host:project'), true);
+ const menuId = menuTrigger.getAttribute('aria-controls');
+ assert.ok(menuId);
+ const newTaskItem = harness.document.getElementById(menuId)?.querySelector('[role="menuitem"]');
+ assert.ok(newTaskItem);
+ await harness.click(newTaskItem);
+ assert.deepEqual(created, ['p', 'p'], 'the menu and shortcut route to the same project action');
+ assert.equal(header?.getAttribute('aria-expanded'), 'false');
+ } finally {
+ await act(async () => { finishCreation(); });
+ await harness.dispose();
+ }
+});
+
/**
* A project group collapses by grid track — `SideNavItem` keeps the rows
* mounted and marks the subtree `inert` — so the rail renders more rows than
diff --git a/packages/ui/src/__tests__/session-history-row-actions.test.tsx b/packages/ui/src/__tests__/session-history-row-actions.test.tsx
index e80c2c877a..103d5bbcc9 100644
--- a/packages/ui/src/__tests__/session-history-row-actions.test.tsx
+++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx
@@ -422,9 +422,11 @@ test('renders collapsible project navigation and row actions as sibling controls
const action = document.querySelector<HTMLButtonElement>(
'button[aria-label="Maka project actions"]',
);
+ const newTask = projectRow?.querySelector<HTMLButtonElement>('button[aria-label="Maka New task"]');
assert.ok(projectRow);
assert.ok(action);
+ assert.ok(newTask);
const navigation = projectRow.querySelector<HTMLButtonElement>(
':scope > div > button[aria-controls]',
);
@@ -439,6 +441,8 @@ test('renders collapsible project navigation and row actions as sibling controls
assert.ok(controlledGroup);
assert.equal(navigation.contains(metadata), true);
assert.equal(navigation.contains(action), false);
+ assert.equal(navigation.contains(newTask), false);
+ assert.equal(action.closest('.maka-project-row-actions'), newTask.closest('.maka-project-row-actions'));
assert.equal(metadata.textContent, '');
assert.equal(navigation.textContent, 'Maka', 'project navigation omits the task-count badge');
assert.equal(controlledGroup.getAttribute('aria-hidden'), 'false');
@@ -449,11 +453,41 @@ test('renders collapsible project navigation and row actions as sibling controls
'project navigation precedes its auxiliary action',
);
assert.equal(projectButtons.indexOf(action), 1, 'project action precedes nested tasks');
+ assert.equal(projectButtons.indexOf(newTask), 2, 'new task follows the menu and precedes nested tasks');
assert.ok(navigation.getAttribute('aria-describedby'));
assertDescriptionReferencesResolve(markup);
assertNoNestedButtons(markup);
});
+for (const scenario of [
+ { name: 'empty', project, sessions: [], hasNewTask: true },
+ { name: 'unavailable', project: { ...project, available: false }, sessions: [session], hasNewTask: false },
+ { name: 'archived', project: { ...project, archivedAt: 1 }, sessions: [session], hasNewTask: false },
+ { name: 'ungrouped', project: undefined, sessions: [session], hasNewTask: false },
+]) {
+ test(`an ${scenario.name} project group offers the correct new-task shortcut`, () => {
+ const markup = renderToStaticMarkup(
+ <LocaleProvider locale="en">
+ <Rail
+ sessions={scenario.sessions}
+ groups={[{ id: 'group', label: 'Maka', project: scenario.project, sessions: scenario.sessions }]}
+ groupVariant="project"
+ projectActions={projectActions}
+ />
+ </LocaleProvider>,
+ );
+ const { document } = parseHTML(markup);
+ const projectRow = document.querySelector('[data-project-id="group"]');
+ assert.ok(projectRow);
+ assert.equal(projectRow.querySelector('button[aria-label="Maka New task"]') !== null, scenario.hasNewTask);
+ assert.equal(
+ projectRow.querySelector('button[aria-label="Maka project actions"]') !== null,
+ scenario.project !== undefined,
+ );
+ assertNoNestedButtons(markup);
+ });
+}
+
test('renders pinned tasks once above project groups', () => {
const pinnedSession: SessionSummary = {
...session,
diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx
index 79f361f66c..ba1e48d108 100644
--- a/packages/ui/src/icons.tsx
+++ b/packages/ui/src/icons.tsx
@@ -174,6 +174,7 @@ export {
Sparkles,
Square,
SquarePen,
+ SquareTerminal,
Sun,
SunMoon,
Target,
diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx
index 9c54b28e82..a23ccf29c8 100644
--- a/packages/ui/src/session-history-list.tsx
+++ b/packages/ui/src/session-history-list.tsx
@@ -39,12 +39,14 @@ import {
AlertTriangle,
Archive,
FolderOpen,
+ Globe,
Pencil,
Pin,
PinOff,
Plug,
Plus,
SquarePen,
+ SquareTerminal,
Unarchive,
} from './icons.js';
import { RelativeTime } from './relative-time.js';
@@ -168,6 +170,11 @@ export interface SessionHistoryGroup {
label: string;
sessions: SessionSummary[];
project?: ProjectRecord;
+ /** Execution location for groups scoped to a Runtime Host. */
+ host?: {
+ kind: 'local' | 'remote' | 'environment';
+ name: string;
+ };
}
/**
@@ -281,8 +288,9 @@ export function SessionHistoryList() {
}
/**
- * A menu is about a set, so opening one on a row outside the set replaces it
- * — the way a file list answers a right-click on an unselected file.
+ * A menu on a row outside the picked set acts on that row alone. Drop the old
+ * picks without picking the menu target: its temporary highlight follows
+ * `data-menu-open`, so dismissing the menu leaves no extra selection behind.
*
* A row already in the set keeps the set: a run built by dragging must
* survive the gesture that asks what to do with it. But the set is narrowed
@@ -305,7 +313,7 @@ export function SessionHistoryList() {
commands.retain(orderedSessionIds());
return;
}
- commands.pick({ sessionId, pick: 'replace', orderedSessionIds: [] });
+ commands.clear();
}
function handleListClickCapture(event: MouseEvent<HTMLDivElement>) {
@@ -340,13 +348,19 @@ export function SessionHistoryList() {
* would take the native menu away and leave nothing in its place.
*/
function handleListContextMenu(event: MouseEvent<HTMLDivElement>) {
- if (!commands) return;
- const row = pickableRowOf(event.target);
- const sessionId = row?.dataset.sessionId;
- if (!row || !sessionId) return;
- const trigger = row.querySelector<HTMLElement>('.maka-session-row-action button');
+ // Project wrappers contain their sessions. Resolve the nearest row first
+ // so a session without actions cannot borrow its project's menu.
+ const row = closestFrom(event.target, '.maka-session-row, .maka-project-row');
+ if (!row || row.closest('[inert]')) return;
+ const sessionId = row.dataset.sessionId;
+ if (sessionId && !isPickableRow(row)) return;
+ const trigger = row.querySelector<HTMLElement>(
+ ':scope > .maka-session-row-action button, '
+ + ':scope > div > .maka-project-row-actions .maka-session-row-action button',
+ );
+ if (!trigger) return;
event.preventDefault();
- adoptForMenu(sessionId);
+ if (sessionId) adoptForMenu(sessionId);
// Chromium fires `contextmenu` before the secondary-button pointerup on
// macOS. Opening a light-dismiss popover here lets that unfinished press
// dismiss the new menu as soon as the button is released. Other platforms
@@ -356,9 +370,9 @@ export function SessionHistoryList() {
pendingContextMenuTriggerRef.current = trigger;
return;
}
- // Opening the menu re-enters `handleListClickCapture` with a synthetic
+ // Opening a session menu re-enters `handleListClickCapture` with a synthetic
// click, so the adoption is dispatched twice — harmless only because both
- // of its branches are idempotent: `replace` sets the same one row, and
+ // of its branches are idempotent: `clear` drops the picks, and
// `retain` intersects with the same list. Anything else here needs a guard.
trigger?.click();
}
@@ -393,6 +407,7 @@ export function SessionHistoryList() {
label: g.label,
sessions: g.sessions,
project: g.project,
+ host: g.host,
}))
: groupSessionsForHistory(rail.sessions, locale).map((g) => ({
key: g.id,
@@ -426,6 +441,7 @@ function SessionListGroups(props: {
label: string;
sessions: SessionSummary[];
project?: ProjectRecord;
+ host?: SessionHistoryGroup['host'];
}>;
}) {
const rail = useSessionRailData();
@@ -589,6 +605,7 @@ function SessionListGroups(props: {
groupKey={group.key}
label={group.label}
project={project}
+ host={group.host}
sessions={sessions}
streamingSessionIds={rail.streamingSessionIds}
projectActions={actions}
@@ -700,6 +717,7 @@ function ProjectNavRow(props: {
groupKey: string;
label: string;
project?: ProjectRecord;
+ host?: SessionHistoryGroup['host'];
sessions: SessionSummary[];
streamingSessionIds?: ReadonlySet<string>;
projectActions?: ProjectRowActions;
@@ -720,10 +738,14 @@ function ProjectNavRow(props: {
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const hoverDescriptionId = useId();
- // The same list the row draws its subtree from. A summary counting rows that
- // were hoisted into the pinned section describes a project row that has no
- // disclosure and no children, and puts its menu somewhere else than the count
- // implies.
+ const copy = getSessionHoverCardCopy(useUiLocale());
+ const executionLocation = props.host
+ ? copy.executionLocation[props.host.kind](props.host.name)
+ : undefined;
+ const icon = props.host?.kind === 'remote'
+ ? Globe
+ : props.host?.kind === 'environment' ? SquareTerminal : FolderOpen;
+ // Count only sessions actually under this row; pinned tasks live elsewhere.
const hoverSummary = useMemo(
() =>
createProjectHoverCardSummary(
@@ -733,10 +755,11 @@ function ProjectNavRow(props: {
),
[props.project, props.sessions, props.streamingSessionIds],
);
- // Collapsible only when there is a real session subtree. An empty VStack is
- // still truthy children for Astryx (!!children) and fabricates a disclosure.
+ // An empty VStack is still truthy children for Astryx (!!children), so only
+ // supply children and collapse behavior when there is a real session subtree.
const hasSessions = props.sessions.length > 0;
const hasActions = props.project !== undefined && props.projectActions !== undefined;
+ const canCreateTask = props.project?.available === true && props.project.archivedAt === undefined;
const hasMeta = (props.project !== undefined && !props.project.available) || hasActions;
const [isDropTarget, setIsDropTarget] = useState(false);
return (
@@ -745,6 +768,7 @@ function ProjectNavRow(props: {
data-project-id={props.groupKey}
data-maka-session-drop-target={props.onDropSession ? 'true' : undefined}
className="maka-project-row"
+ data-host-kind={props.host?.kind}
data-drop-target={isDropTarget ? 'true' : undefined}
onDragOver={(event) => {
// A drag only lands where the shell says this Session may go, so a row
@@ -771,12 +795,13 @@ function ProjectNavRow(props: {
key="navigation"
label={props.label}
aria-describedby={hoverDescriptionId}
- icon={FolderOpen}
+ icon={icon}
collapsible={hasSessions ? { defaultIsCollapsed: false } : undefined}
endContent={hasMeta ? (
<ProjectItemMeta
project={props.project}
reserveAction={hasActions}
+ reserveNewTask={hasActions && canCreateTask}
/>
) : undefined}
trailingAction={
@@ -785,7 +810,7 @@ function ProjectNavRow(props: {
project={props.project}
actions={props.projectActions}
onStartRename={props.onStartRename}
- position={hasSessions ? 'before-disclosure' : 'trailing'}
+ canCreateTask={canCreateTask}
/>
) : undefined
}
@@ -800,11 +825,14 @@ function ProjectNavRow(props: {
<ProjectHoverCardDescription
id={hoverDescriptionId}
summary={hoverSummary}
- /> <ProjectHoverCardLayer
+ executionLocation={executionLocation}
+ />
+ <ProjectHoverCardLayer
containerRef={containerRef}
label={props.label}
project={props.project}
summary={hoverSummary}
+ executionLocation={executionLocation}
/>
</div>
);
@@ -815,6 +843,7 @@ const ProjectHoverCardLayer = memo(function ProjectHoverCardLayer(props: {
label: string;
project?: ProjectRecord;
summary: ProjectHoverCardSummary;
+ executionLocation?: string;
}) {
const locale = useUiLocale();
const copy = getSessionHoverCardCopy(locale);
@@ -834,6 +863,7 @@ const ProjectHoverCardLayer = memo(function ProjectHoverCardLayer(props: {
<ProjectHoverCardContent
label={props.label}
summary={props.summary}
+ executionLocation={props.executionLocation}
locale={locale}
/>,
);
@@ -1192,11 +1222,13 @@ function SessionHoverCardContent(props: {
function ProjectHoverCardDescription(props: {
id: string;
summary: ProjectHoverCardSummary;
+ executionLocation?: string;
}) {
const locale = useUiLocale();
const copy = getSessionHoverCardCopy(locale);
const summary = props.summary;
const description = [
+ props.executionLocation,
summary.path,
copy.taskCount(summary.taskCount),
summary.runningCount > 0 ? copy.runningTaskCount(summary.runningCount) : undefined,
@@ -1221,6 +1253,7 @@ function ProjectHoverCardDescription(props: {
function ProjectHoverCardContent(props: {
label: string;
summary: ProjectHoverCardSummary;
+ executionLocation?: string;
locale: UiLocale;
}) {
const copy = getSessionHoverCardCopy(props.locale);
@@ -1229,6 +1262,9 @@ function ProjectHoverCardContent(props: {
return (
<span className="maka-sidebar-hover-card" data-kind="project">
<span className="maka-sidebar-hover-card-title">{props.label}</span>
+ {props.executionLocation ? (
+ <span className="maka-sidebar-hover-card-location">{props.executionLocation}</span>
+ ) : null}
{summary.path ? (
<span className="maka-sidebar-hover-card-path" title={summary.path}>
{summary.path}
@@ -1315,6 +1351,7 @@ function preferredProjectPath(project: ProjectRecord | undefined): string | unde
function ProjectItemMeta(props: {
project?: ProjectRecord;
reserveAction: boolean;
+ reserveNewTask: boolean;
}) {
const copy = getConversationCopy(useUiLocale()).sessions;
return (
@@ -1323,7 +1360,11 @@ function ProjectItemMeta(props: {
<AlertTriangle size={ICON_SIZE.meta} aria-label={copy.projectUnavailable} />
)}
{props.reserveAction ? (
- <span className="maka-session-row-trailing" aria-hidden="true" />
+ <span
+ className="maka-session-row-trailing"
+ data-new-task={props.reserveNewTask ? 'true' : undefined}
+ aria-hidden="true"
+ />
) : null}
</span>
);
@@ -1333,7 +1374,7 @@ function ProjectItemActions(props: {
project: ProjectRecord;
actions: ProjectRowActions;
onStartRename(opener: HTMLElement | null): void;
- position: 'before-disclosure' | 'trailing';
+ canCreateTask: boolean;
}) {
const copy = getConversationCopy(useUiLocale()).sessions;
const trailingRef = useRef<HTMLSpanElement>(null);
@@ -1367,9 +1408,8 @@ function ProjectItemActions(props: {
})();
}
- // Projects keep a permanent MoreMenu. SideNavItem's trailingAction slot puts
- // it after the collapse button and before the nested tasks, so visual and
- // keyboard order agree without nesting either interactive control.
+ // Both controls share the same pending-action guard and remain siblings of
+ // the collapse button, ahead of the nested tasks in keyboard order.
const menuItems = project.archivedAt !== undefined
? [
{
@@ -1379,7 +1419,7 @@ function ProjectItemActions(props: {
},
]
: [
- ...(project.available
+ ...(props.canCreateTask
? [
{
label: copy.projectNewTask,
@@ -1412,15 +1452,29 @@ function ProjectItemActions(props: {
];
return (
- <span className="maka-session-row-action" data-position={props.position} ref={trailingRef}>
- <MoreMenu
- size="sm"
- label={copy.projectActionsAriaLabel(project.name)}
- isDisabled={pendingAction !== null}
- isMenuOpen={menuOpen}
- onOpenChange={setMenuOpen}
- items={menuItems}
- />
+ <span className="maka-project-row-actions">
+ <span className="maka-session-row-action" ref={trailingRef}>
+ <MoreMenu
+ size="sm"
+ label={copy.projectActionsAriaLabel(project.name)}
+ isDisabled={pendingAction !== null}
+ isMenuOpen={menuOpen}
+ onOpenChange={setMenuOpen}
+ items={menuItems}
+ />
+ </span>
+ {props.canCreateTask && (
+ <Button
+ variant="ghost"
+ size="sm"
+ isIconOnly
+ label={`${project.name} ${copy.projectNewTask}`}
+ tooltip={copy.projectNewTask}
+ icon={<SquarePen size={ICON_SIZE.control} aria-hidden="true" />}
+ isDisabled={pendingAction !== null}
+ onClick={() => runProjectAction('new', () => actions.onNew(project.id))}
+ />
+ )}
</span>
);
}
diff --git a/packages/ui/src/session-hover-card-copy.ts b/packages/ui/src/session-hover-card-copy.ts
index 66c9dc544a..68c6a8d8ce 100644
--- a/packages/ui/src/session-hover-card-copy.ts
+++ b/packages/ui/src/session-hover-card-copy.ts
@@ -32,6 +32,7 @@ interface SessionHoverCardCopy {
locationCount(count: number): string;
projectAvailable: string;
projectUnavailable: string;
+ executionLocation: Record<'local' | 'remote' | 'environment', (name: string) => string>;
}
const COPY: Record<UiLocale, SessionHoverCardCopy> = resolveUiMessageCatalog(defineShippedUiMessageCatalog<SessionHoverCardCopy>()({
@@ -46,6 +47,11 @@ const COPY: Record<UiLocale, SessionHoverCardCopy> = resolveUiMessageCatalog(def
locationCount: (count) => `${count} 个位置`,
projectAvailable: '目录可用',
projectUnavailable: '目录不可用',
+ executionLocation: {
+ local: () => '执行位置:本机',
+ remote: (name) => `执行位置:远程 · ${name}`,
+ environment: (name) => `执行位置:WSL · ${name}`,
+ },
},
'zh-TW': {
sessionDetailsLabel: (name) => `${name} 任務詳細資料`,
@@ -58,6 +64,11 @@ const COPY: Record<UiLocale, SessionHoverCardCopy> = resolveUiMessageCatalog(def
locationCount: (count) => `${count} 個位置`,
projectAvailable: '目錄可用',
projectUnavailable: '目錄不可用',
+ executionLocation: {
+ local: () => '執行位置:本機',
+ remote: (name) => `執行位置:遠端 · ${name}`,
+ environment: (name) => `執行位置:WSL · ${name}`,
+ },
},
en: {
sessionDetailsLabel: (name) => `${name} task details`,
@@ -70,6 +81,11 @@ const COPY: Record<UiLocale, SessionHoverCardCopy> = resolveUiMessageCatalog(def
locationCount: (count) => `${count} ${count === 1 ? 'location' : 'locations'}`,
projectAvailable: 'Directory available',
projectUnavailable: 'Directory unavailable',
+ executionLocation: {
+ local: () => 'Execution location: This computer',
+ remote: (name) => `Execution location: Remote · ${name}`,
+ environment: (name) => `Execution location: WSL · ${name}`,
+ },
},
}));
diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx
index 5c051e6ff0..156440634b 100644
--- a/packages/ui/src/session-rail-context.tsx
+++ b/packages/ui/src/session-rail-context.tsx
@@ -146,9 +146,8 @@ export interface SessionRailChrome {
/** What a click on a session row means, decided by the modifier held with it. */
export type SessionRowPick =
/**
- * Plain click, and the adoption a menu makes when it opens on an unpicked
- * row: the set becomes exactly this row. Opening the task is the row's own
- * business — this names what happens to the SET.
+ * Plain click: the set becomes exactly this row. Opening the task is the
+ * row's own business — this names what happens to the SET.
*/
| 'replace'
/** ⌘/Ctrl: add or remove this one row, leaving the rest of the set alone. */
diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx
index afa511b621..584895d9e7 100644
--- a/packages/ui/stories/session-list-panel.stories.tsx
+++ b/packages/ui/stories/session-list-panel.stories.tsx
@@ -554,7 +554,7 @@ export const PinnedAndRecentSections: Story = {
// Real path: group-by-project — collapsible project rows, sessions on the
// project row's left edge so both titles and hover fills share one x, worktree
-// mark + project actions.
+// mark + project actions, with local, remote and WSL execution locations.
export const ProjectGroups: Story = {
render: () => {
const maka = makeProject({
@@ -576,6 +576,11 @@ export const ProjectGroups: Story = {
name: '旧版桌面端',
available: false,
});
+ const environment = makeProject({
+ id: 'project-environment',
+ name: 'Linux 工具',
+ preferredPath: '/home/user/linux-tools',
+ });
const sessions = [
makeSession({
id: 'proj-main',
@@ -624,23 +629,34 @@ export const ProjectGroups: Story = {
id: `project:${maka.id}`,
label: maka.name,
project: maka,
+ host: { kind: 'local', name: 'Local' },
sessions: [sessions[0]!, sessions[1]!],
},
{
id: `project:${docs.id}`,
label: docs.name,
project: docs,
+ host: { kind: 'remote', name: '开发服务器' },
sessions: [sessions[2]!],
},
{
id: `project:${missing.id}`,
label: missing.name,
project: missing,
+ host: { kind: 'local', name: 'Local' },
+ sessions: [],
+ },
+ {
+ id: `project:${environment.id}`,
+ label: environment.name,
+ project: environment,
+ host: { kind: 'environment', name: 'Ubuntu' },
sessions: [],
},
{
id: '__ungrouped__',
label: '未归属项目',
+ host: { kind: 'local', name: 'Local' },
sessions: [sessions[3]!],
},
],
@@ -670,6 +686,7 @@ export const ProjectGroups: Story = {
const action = within(projectRow).getByRole('button', {
name: 'maka-agent 项目操作',
});
+ const newTask = within(projectRow).getByRole('button', { name: 'maka-agent 新建任务' });
const groupId = navigation.getAttribute('aria-controls');
if (!groupId) throw new Error('project navigation does not own a group');
const group = canvasElement.ownerDocument.getElementById(groupId);
@@ -690,11 +707,25 @@ export const ProjectGroups: Story = {
expect(Math.abs(
projectTitle.getBoundingClientRect().x - taskTitle.getBoundingClientRect().x,
)).toBeLessThanOrEqual(2);
+ const chevron = navigation.querySelector(':scope > .astryx-icon:last-child');
+ if (!chevron) throw new Error('project collapse decoration is missing');
+ await expect(chevron).not.toBeVisible();
+ expect(projectTitle.getBoundingClientRect().right).toBeLessThanOrEqual(
+ action.getBoundingClientRect().left,
+ );
+ expect(action.getBoundingClientRect().right).toBeLessThanOrEqual(
+ newTask.getBoundingClientRect().left,
+ );
+ expect(newTask.getBoundingClientRect().right).toBeLessThanOrEqual(projectBox.right);
navigation.focus();
await userEvent.tab();
await expect(action).toHaveFocus();
await userEvent.tab();
+ await expect(newTask).toHaveFocus();
+ await userEvent.keyboard('{Enter}');
+ await expect(navigation).toHaveAttribute('aria-expanded', 'true');
+ await userEvent.tab();
await expect(taskControl).toHaveFocus();
navigation.focus();
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org