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