From f093555ba2c3c0b699131b26e89c03a56e20d97a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 9 Aug 2026 10:17:04 +0800 Subject: [PATCH 1/3] fix(ui): separate session row actions from navigation --- apps/desktop/src/renderer/styles/sidebar.css | 17 ++ packages/ui/src/session-history-list.tsx | 237 ++++++++++--------- 2 files changed, 146 insertions(+), 108 deletions(-) diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 1b64c4c435..4ad00614c1 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -190,6 +190,7 @@ } .maka-session-row { + position: relative; min-width: 0; -webkit-app-region: no-drag; } @@ -267,6 +268,22 @@ min-height: var(--h-control-sm, 24px); } +/* SideNavItem owns the row button and only supports non-interactive + * endContent. The action menu occupies the reserved trailing slot visually, + * but remains its sibling in the DOM so neither control contains the other. */ +.maka-session-row-action { + position: absolute; + inset-block-start: calc( + (var(--size-element-md) - var(--size-element-sm)) / 2 + ); + inset-inline-end: var(--space-2); + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--h-control-sm, 24px); +} + .maka-session-row-trailing-spacer { display: block; width: 8px; diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 46cb16e7cc..18f5b612ef 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -440,7 +440,11 @@ const SessionNavRow = memo(function SessionNavRow(props: { onClick={(event) => { if (event.detail > 1 && props.actions) { props.onStartRename( - { kind: 'session', id: props.session.id, name: props.session.name }, + { + kind: 'session', + id: props.session.id, + name: props.session.name, + }, // The row's own button: a double-click starts the rename from // the row itself, not from the actions menu. event.currentTarget as HTMLElement, @@ -450,17 +454,23 @@ const SessionNavRow = memo(function SessionNavRow(props: { props.onSelectSession(props.session.id); }} endContent={ - } /> + {props.actions && ( + + )} ); }); @@ -578,49 +588,18 @@ function ProjectItemEndContent(props: { ); } -const SessionItemEndContent = memo(function SessionItemEndContent(props: { +const SessionItemMeta = memo(function SessionItemMeta(props: { session: SessionSummary; active: boolean; streaming: boolean; stale: boolean; worktree: boolean; - actions?: SessionRowActions; - onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void; + reserveAction: boolean; }) { const locale = useUiLocale(); - const trailingRef = useRef(null); const copy = getConversationCopy(locale).sessions; - const [menuOpen, setMenuOpen] = useState(false); - const [pendingAction, setPendingAction] = useState(null); - const mountedRef = useMountedRef(); - const pendingActionRef = useRef(null); - const pendingMenuIntentRef = useRef<(() => void) | null>(null); - const actions = props.actions; const statusDot = resolveSessionStatusDot(props.session, props.streaming, props.active, locale); - useEffect( - () => () => { - pendingActionRef.current = null; - }, - [], - ); - - function runRowAction(actionId: SessionRowActionId, action: () => void | Promise) { - if (pendingActionRef.current) return; - pendingActionRef.current = actionId; - setPendingAction(actionId); - void (async () => { - try { - await action(); - } catch { - // AppShell owns visible session-action failure feedback. - } finally { - pendingActionRef.current = null; - if (mountedRef.current) setPendingAction(null); - } - })(); - } - // Signal (status/unread) and action (MoreMenu) are orthogonal — same as // project rows (badge + menu). No hover XOR state machine. const signal = statusDot ? ( @@ -636,7 +615,7 @@ const SessionItemEndContent = memo(function SessionItemEndContent(props: { ) : null; return ( - + {props.stale && ( {/* `yellow`, not `warning`. Astryx keeps two archives: the semantic @@ -666,79 +645,121 @@ const SessionItemEndContent = memo(function SessionItemEndContent(props: { /> )} {signal} - {actions ? ( - - { - setMenuOpen(open); - if (open) return; - const intent = pendingMenuIntentRef.current; - pendingMenuIntentRef.current = null; - if (intent) { - window.requestAnimationFrame(() => { - intent(); - }); - } - }} - items={[ - { - label: props.session.isFlagged ? copy.unpin : copy.pin, - icon: props.session.isFlagged ? PinOff : Pin, - onClick: () => - runRowAction('flag', () => - actions.onToggleFlag(props.session.id, !props.session.isFlagged), - ), - }, - { - label: copy.rename, - icon: Pencil, - onClick: () => { - // Read now, while the trigger is still the thing the user is - // on: by the time the intent runs the menu has closed and - // focus is mid-handover. - const opener = trailingRef.current?.querySelector('button') ?? null; - pendingMenuIntentRef.current = () => - props.onStartRename( - { kind: 'session', id: props.session.id, name: props.session.name }, - opener, - ); - }, - }, - { - label: props.session.isArchived ? copy.unarchive : copy.archive, - icon: props.session.isArchived ? ArchiveRestore : Archive, - onClick: () => - runRowAction('archive', () => - props.session.isArchived - ? actions.onUnarchive(props.session.id) - : actions.onArchive(props.session.id), - ), - }, - { type: 'divider' }, - { - label: copy.delete, - icon: Trash2, - onClick: () => { - pendingMenuIntentRef.current = () => - runRowAction('delete', () => actions.onDelete(props.session.id)); - }, - }, - ]} - /> - - ) : ( - - {signal ? null : - )} - + ); }); +function SessionItemActions(props: { + session: SessionSummary; + actions: SessionRowActions; + onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void; +}) { + const trailingRef = useRef(null); + const copy = getConversationCopy(useUiLocale()).sessions; + const [menuOpen, setMenuOpen] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const mountedRef = useMountedRef(); + const pendingActionRef = useRef(null); + const pendingMenuIntentRef = useRef<(() => void) | null>(null); + const actions = props.actions; + + useEffect( + () => () => { + pendingActionRef.current = null; + }, + [], + ); + + function runRowAction(actionId: SessionRowActionId, action: () => void | Promise) { + if (pendingActionRef.current) return; + pendingActionRef.current = actionId; + setPendingAction(actionId); + void (async () => { + try { + await action(); + } catch { + // AppShell owns visible session-action failure feedback. + } finally { + pendingActionRef.current = null; + if (mountedRef.current) setPendingAction(null); + } + })(); + } + + return ( + event.stopPropagation()} + > + { + setMenuOpen(open); + if (open) return; + const intent = pendingMenuIntentRef.current; + pendingMenuIntentRef.current = null; + if (intent) window.requestAnimationFrame(intent); + }} + items={[ + { + label: props.session.isFlagged ? copy.unpin : copy.pin, + icon: props.session.isFlagged ? PinOff : Pin, + onClick: () => + runRowAction('flag', () => + actions.onToggleFlag(props.session.id, !props.session.isFlagged), + ), + }, + { + label: copy.rename, + icon: Pencil, + onClick: () => { + // Read now, while the trigger is still the thing the user is on: + // by the time the intent runs the menu has closed and focus is + // mid-handover. + const opener = trailingRef.current?.querySelector('button') ?? null; + pendingMenuIntentRef.current = () => + props.onStartRename( + { + kind: 'session', + id: props.session.id, + name: props.session.name, + }, + opener, + ); + }, + }, + { + label: props.session.isArchived ? copy.unarchive : copy.archive, + icon: props.session.isArchived ? ArchiveRestore : Archive, + onClick: () => + runRowAction('archive', () => + props.session.isArchived + ? actions.onUnarchive(props.session.id) + : actions.onArchive(props.session.id), + ), + }, + { type: 'divider' }, + { + label: copy.delete, + icon: Trash2, + onClick: () => { + pendingMenuIntentRef.current = () => + runRowAction('delete', () => actions.onDelete(props.session.id)); + }, + }, + ]} + /> + + ); +} + function resolveSessionStatusDot( session: SessionSummary, streaming: boolean, From f705d504870bd86c1f1af2a75d6259d0ba46d3d8 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 9 Aug 2026 18:03:34 +0800 Subject: [PATCH 2/3] test(ui): cover session row sibling controls --- .../session-history-row-actions.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/ui/src/__tests__/session-history-row-actions.test.tsx diff --git a/packages/ui/src/__tests__/session-history-row-actions.test.tsx b/packages/ui/src/__tests__/session-history-row-actions.test.tsx new file mode 100644 index 0000000000..3625e82cf4 --- /dev/null +++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { SessionSummary } from '@maka/core'; +import { LocaleProvider } from '../locale-context.js'; +import { SessionHistoryList, type SessionRowActions } from '../session-history-list.js'; + +const session: SessionSummary = { + id: 'session-1', + name: 'Release notes', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'test-connection', + connectionLocked: true, + model: 'test-model', + permissionMode: 'ask', +}; + +const rowActions: SessionRowActions = { + onToggleFlag: () => undefined, + onArchive: () => undefined, + onUnarchive: () => undefined, + onRename: () => undefined, + onDelete: () => undefined, +}; + +test('renders session navigation and row actions as sibling controls', () => { + const markup = renderToStaticMarkup( + + undefined} + rowActions={rowActions} + /> + , + ); + + assert.equal((markup.match(/)[\s\S])* Date: Wed, 12 Aug 2026 21:35:00 +0800 Subject: [PATCH 3/3] test(ui): use the session type entry point --- packages/ui/src/__tests__/session-history-row-actions.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3625e82cf4..254a9fa362 100644 --- a/packages/ui/src/__tests__/session-history-row-actions.test.tsx +++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { renderToStaticMarkup } from 'react-dom/server'; -import type { SessionSummary } from '@maka/core'; +import type { SessionSummary } from '@maka/core/session'; import { LocaleProvider } from '../locale-context.js'; import { SessionHistoryList, type SessionRowActions } from '../session-history-list.js';