From 00df6f4b1bff3a303a2ed7a420e8c756545a383c Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 26 Jun 2026 23:18:03 +0800 Subject: [PATCH 1/4] fix: include persisted remote hostname in session workspace matching --- .../flow_chat/utils/sessionOrdering.test.ts | 18 ++++++++++++++++++ .../src/flow_chat/utils/sessionOrdering.ts | 9 +++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts index 77384b0d2..8c2df084d 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts @@ -131,4 +131,22 @@ describe('sessionOrdering', () => { ) ).toBe(true); }); + + it('remote SSH: matches persisted metadata that only has workspaceHostname', () => { + const session = { + workspacePath: '/home/u/project-a', + remoteConnectionId: undefined, + remoteSshHost: undefined, + workspaceHostname: 'myserver.example.com', + }; + + expect( + sessionBelongsToWorkspaceNavRow( + session, + '/home/u/project-a', + 'ssh-user@myserver.example.com:22', + undefined + ) + ).toBe(true); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts index 3646ac945..e8da82d77 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -25,7 +25,9 @@ function effectiveWorkspaceSshHost( * We must never treat "same host" as sufficient: two tabs to the same server at `/a` vs `/b` are distinct. */ export function sessionBelongsToWorkspaceNavRow( - session: Pick, + session: Pick & { + workspaceHostname?: string | null; + }, workspacePath: string, remoteConnectionId?: string | null, remoteSshHost?: string | null @@ -38,7 +40,10 @@ export function sessionBelongsToWorkspaceNavRow( const wsConn = remoteConnectionId?.trim() ?? ''; const sessConn = session.remoteConnectionId?.trim() ?? ''; const wsHostEff = effectiveWorkspaceSshHost(remoteSshHost, remoteConnectionId); - const sessHost = session.remoteSshHost?.trim().toLowerCase() ?? ''; + const sessHost = + session.remoteSshHost?.trim().toLowerCase() || + session.workspaceHostname?.trim().toLowerCase() || + ''; const sessConnHost = hostFromSshConnectionId(sessConn); const wsConnHost = hostFromSshConnectionId(wsConn); From 581d6d7b1b9b66177aeab95f862c8bedd32027f7 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 26 Jun 2026 23:47:13 +0800 Subject: [PATCH 2/4] fix(web-ui): refine slash command picker dismissal - Avoid opening the slash command picker for path-like input such as /users/alice - Close the picker once the leading slash token contains whitespace, such as /b - Preserve trailing spaces for slash-leading rich text input so picker dismissal can detect them - Keep input content when dismissing the slash picker with Escape - Add focused tests for slash picker query parsing and rich text trailing spaces --- .../src/flow_chat/components/ChatInput.tsx | 14 ++++----- .../components/RichTextInput.test.tsx | 22 ++++++++++++++ .../flow_chat/components/RichTextInput.tsx | 10 ++++++- .../src/flow_chat/utils/slashCommand.test.ts | 30 +++++++++++++++++++ .../src/flow_chat/utils/slashCommand.ts | 17 +++++++++++ 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index b5685a4a9..5926d958c 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -29,7 +29,7 @@ import type { FileContext, DirectoryContext, ImageContext } from '@/types/contex import { SmartRecommendations } from './smart-recommendations'; import { useCurrentWorkspace, useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createImageContextFromFile, createImageContextFromClipboard } from '../utils/imageUtils'; -import { isSlashCommand, stripSlashCommand } from '../utils/slashCommand'; +import { getSlashCommandPickerQuery, isSlashCommand, stripSlashCommand } from '../utils/slashCommand'; import { notificationService } from '@/shared/notification-system'; import { inputReducer, initialInputState } from '../reducers/inputReducer'; import { modeReducer, initialModeState } from '../reducers/modeReducer'; @@ -1828,7 +1828,8 @@ export const ChatInput: React.FC = ({ if (text.startsWith('/')) { const afterSlash = text.slice(1); const hasWhitespace = /\s/.test(afterSlash); - const query = afterSlash.trimStart().split(/\s+/, 1)[0]?.toLowerCase?.() ?? ''; + const pickerQuery = getSlashCommandPickerQuery(text); + const query = pickerQuery ?? afterSlash.trimStart().split(/\s+/, 1)[0]?.toLowerCase?.() ?? ''; const matchedMcpPrompt = localSlashCommandsEnabled ? resolveTypedMcpPromptCommand(text) : null; @@ -1852,7 +1853,7 @@ export const ChatInput: React.FC = ({ // Only show the picker for "/..." patterns that are plausibly a command (/ or /b... /d...). // Once the user types a space (starts composing the real question), stop showing the picker // so Enter can submit "/btw ..." or "/DeepReview ..." instead of selecting from the picker. - if (!hasWhitespace && (query === '' || query.startsWith('b') || query.startsWith('d') || query.startsWith('g') || query.startsWith('u'))) { + if (pickerQuery !== null && (query === '' || query.startsWith('b') || query.startsWith('d') || query.startsWith('g') || query.startsWith('u'))) { setSlashCommandState({ isActive: true, kind: 'actions', @@ -1866,7 +1867,7 @@ export const ChatInput: React.FC = ({ } // When idle, keep the picker for mode switching, but don't interfere with executable slash commands. - if (!isBtwCommand && !isGoalCommand && !isCompactCommand && !isUsageCommand && !isDeepReviewCommand && !matchedMcpPrompt) { + if (pickerQuery !== null && !isBtwCommand && !isGoalCommand && !isCompactCommand && !isUsageCommand && !isDeepReviewCommand && !matchedMcpPrompt) { setSlashCommandState({ isActive: true, kind: 'all', @@ -2985,11 +2986,6 @@ export const ChatInput: React.FC = ({ getRichTextInlineTriggerController()?.closeInlineTrigger?.(); } setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); - - // For mode switching picker, "/" is just a trigger and should be cleared on cancel. - if (kind !== 'actions' && kind !== 'skills') { - dispatchInput({ type: 'CLEAR_VALUE' }); - } return; } diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx index 632d52739..bae4b5eb7 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx @@ -168,6 +168,28 @@ describeWithJsdom('RichTextInput external sync', () => { expect(editor.firstChild).toBe(originalTextNode); }); + it('preserves trailing spaces emitted by user input', async () => { + const onChange = vi.fn(); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input'); + expect(editor).toBeInstanceOf(HTMLDivElement); + + await updateEditorText(editor as HTMLDivElement, '/b '); + + expect(onChange).toHaveBeenLastCalledWith('/b ', emptyContexts); + }); + it('replaces the DOM node when value changes externally', async () => { const harnessRef = createRef(); const editor = await renderHarness(harnessRef); diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index 82deea447..fcd570fee 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -64,6 +64,10 @@ function isWhitespaceCharacter(char: string | undefined): boolean { return !char || /\s/.test(char); } +function trimEdgeLineBreaks(text: string): string { + return text.replace(/^[\r\n]+/, '').replace(/[\r\n]+$/, ''); +} + function getContextDisplayName(context: ContextItem): string { switch (context.type) { case 'file': return context.fileName; @@ -448,7 +452,11 @@ export const RichTextInput = React.forwardRef { }); }); +describe('isSlashCommandPickerQuery', () => { + it('allows single-token command picker queries', () => { + expect(isSlashCommandPickerQuery('')).toBe(true); + expect(isSlashCommandPickerQuery('goal')).toBe(true); + expect(isSlashCommandPickerQuery('mcp:foo-bar')).toBe(true); + }); + + it('rejects path-like queries with another slash', () => { + expect(isSlashCommandPickerQuery('users/alice')).toBe(false); + expect(isSlashCommandPickerQuery('foo/bar/baz')).toBe(false); + }); +}); + +describe('getSlashCommandPickerQuery', () => { + it('returns a lowercase query for active picker text', () => { + expect(getSlashCommandPickerQuery('/')).toBe(''); + expect(getSlashCommandPickerQuery('/Goal')).toBe('goal'); + expect(getSlashCommandPickerQuery('/mcp:foo-bar')).toBe('mcp:foo-bar'); + }); + + it('closes the picker once the slash token is no longer active', () => { + expect(getSlashCommandPickerQuery('/goal ')).toBeNull(); + expect(getSlashCommandPickerQuery('/goal focus')).toBeNull(); + expect(getSlashCommandPickerQuery('/users/alice')).toBeNull(); + expect(getSlashCommandPickerQuery('hello /goal')).toBeNull(); + }); +}); + describe('stripSlashCommand', () => { it('removes the command token and immediate whitespace', () => { expect(stripSlashCommand('/btw question?', '/btw')).toBe('question?'); diff --git a/src/web-ui/src/flow_chat/utils/slashCommand.ts b/src/web-ui/src/flow_chat/utils/slashCommand.ts index e485c03ed..4d90d6737 100644 --- a/src/web-ui/src/flow_chat/utils/slashCommand.ts +++ b/src/web-ui/src/flow_chat/utils/slashCommand.ts @@ -1,5 +1,22 @@ const COMMAND_BOUNDARY_RE = /^(\/[A-Za-z][\w:-]*)(?=\s|$)/; +export function isSlashCommandPickerQuery(query: string): boolean { + return typeof query === 'string' && !query.includes('/'); +} + +export function getSlashCommandPickerQuery(text: string): string | null { + if (typeof text !== 'string' || !text.startsWith('/')) { + return null; + } + + const query = text.slice(1); + if (/\s/.test(query) || !isSlashCommandPickerQuery(query)) { + return null; + } + + return query.toLowerCase(); +} + export function matchesSlashCommand(text: string): string | null { if (typeof text !== 'string' || text.length === 0 || !text.startsWith('/')) { return null; From 9c267d187dc268a0bebf40a823f7bb56f1661c48 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 26 Jun 2026 23:55:54 +0800 Subject: [PATCH 3/4] feat(web-ui): add copy session ID action to nav session menu - add a Copy ID item to the session row overflow menu - copy session IDs with the shared clipboard fallback helper - add localized copy success/failure messages - document the new session menu test IDs --- docs/development/ui-testids-CN.md | 3 ++ docs/development/ui-testids.md | 3 ++ .../sections/sessions/SessionsSection.tsx | 41 ++++++++++++++++++- src/web-ui/src/locales/en-US/common.json | 3 ++ src/web-ui/src/locales/zh-CN/common.json | 3 ++ src/web-ui/src/locales/zh-TW/common.json | 3 ++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/development/ui-testids-CN.md b/docs/development/ui-testids-CN.md index ab6058e28..d7a80ab07 100644 --- a/docs/development/ui-testids-CN.md +++ b/docs/development/ui-testids-CN.md @@ -159,6 +159,9 @@ | 会话菜单按钮 | `nav-session-menu-btn` | 打开行操作菜单。配合 `data-session-id` 使用。 | | 会话菜单 | `nav-session-menu` | 单个会话的 portal 菜单。配合 `data-session-id` 使用。 | | 会话重命名项 | `nav-session-menu-rename` | 开始重命名会话。 | +| 会话复制 ID 项 | `nav-session-menu-copy-id` | 复制会话 ID。配合 `data-session-id` 使用。 | +| 会话定时任务项 | `nav-session-menu-scheduled-jobs` | 打开该会话的定时任务。配合 `data-session-id` 使用。 | +| 会话归档项 | `nav-session-menu-archive` | 归档会话。配合 `data-session-id` 使用。 | | 会话删除项 | `nav-session-menu-delete` | 删除会话。 | | 会话列表展开按钮 | `nav-session-list-toggle` | 展开/折叠长会话列表。 | diff --git a/docs/development/ui-testids.md b/docs/development/ui-testids.md index 5adc253b0..38697873a 100644 --- a/docs/development/ui-testids.md +++ b/docs/development/ui-testids.md @@ -160,6 +160,9 @@ Avoid adding IDs to these surfaces unless there is a clear automated workflow. | Session menu button | `nav-session-menu-btn` | Opens row action menu. Pair with `data-session-id`. | | Session menu | `nav-session-menu` | Portal menu for one session. Pair with `data-session-id`. | | Session rename item | `nav-session-menu-rename` | Starts session rename. | +| Session copy ID item | `nav-session-menu-copy-id` | Copies the session ID. Pair with `data-session-id`. | +| Session scheduled jobs item | `nav-session-menu-scheduled-jobs` | Opens scheduled jobs for the session. Pair with `data-session-id`. | +| Session archive item | `nav-session-menu-archive` | Archives the session. Pair with `data-session-id`. | | Session delete item | `nav-session-menu-delete` | Deletes session. | | Session list toggle | `nav-session-list-toggle` | Expands/collapses long session lists. | diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 842dc3ad0..561b6e4ac 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -7,7 +7,7 @@ import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3 } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3, Copy } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -48,6 +48,8 @@ import type { } from '@/flow_chat/utils/backgroundSubagentActivity'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; +import { notificationService } from '@/shared/notification-system'; +import { copyTextToClipboard } from '@/shared/utils/textSelection'; import { scheduleAfterStartupPaint, scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; import { SESSION_METADATA_DEFERRED_FALLBACK_MS, @@ -774,7 +776,7 @@ const SessionsSection: React.FC = ({ } const btn = e.currentTarget as HTMLElement; const rect = btn.getBoundingClientRect(); - const { top, left } = computeFixedPopoverPosition(rect, 160, 96, 4, 8); + const { top, left } = computeFixedPopoverPosition(rect, 160, 120, 4, 8); setSessionMenuPosition({ top, left }); setOpenMenuSessionId(sessionId); }, @@ -811,6 +813,19 @@ const SessionsSection: React.FC = ({ [t] ); + const handleCopySessionId = useCallback( + async (e: React.MouseEvent, sessionId: string) => { + e.stopPropagation(); + const copied = await copyTextToClipboard(sessionId); + if (copied) { + notificationService.success(t('nav.sessions.copySessionIdSuccess'), { duration: 2000 }); + } else { + notificationService.error(t('nav.sessions.copySessionIdFailed'), { duration: 3000 }); + } + }, + [t] + ); + const handleStartEdit = useCallback( (e: React.MouseEvent, session: Session) => { e.stopPropagation(); @@ -1166,6 +1181,8 @@ const SessionsSection: React.FC = ({ ref={openMenuSessionId === session.sessionId ? sessionMenuAnchorRef : undefined} className={`bitfun-nav-panel__inline-item-action-btn${openMenuSessionId === session.sessionId ? ' is-open' : ''}`} onClick={e => handleMenuOpen(e, session.sessionId)} + data-testid="nav-session-menu-btn" + data-session-id={session.sessionId} > @@ -1176,15 +1193,29 @@ const SessionsSection: React.FC = ({ className="bitfun-nav-panel__inline-item-menu-popover" role="menu" style={{ top: `${sessionMenuPosition.top}px`, left: `${sessionMenuPosition.left}px` }} + data-testid="nav-session-menu" + data-session-id={session.sessionId} > +