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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/development/ui-testids-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 展开/折叠长会话列表。 |

Expand Down
3 changes: 3 additions & 0 deletions docs/development/ui-testids.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -774,7 +776,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
}
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);
},
Expand Down Expand Up @@ -811,6 +813,19 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
[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();
Expand Down Expand Up @@ -1166,6 +1181,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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}
>
<MoreHorizontal size="var(--bitfun-nav-row-action-icon-size)" />
</button>
Expand All @@ -1176,15 +1193,29 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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}
>
<button
type="button"
className="bitfun-nav-panel__inline-item-menu-item"
onClick={e => { setOpenMenuSessionId(null); handleStartEdit(e, session); }}
data-testid="nav-session-menu-rename"
data-session-id={session.sessionId}
>
<Pencil size={13} />
<span>{t('nav.sessions.rename')}</span>
</button>
<button
type="button"
className="bitfun-nav-panel__inline-item-menu-item"
onClick={e => { setOpenMenuSessionId(null); void handleCopySessionId(e, session.sessionId); }}
data-testid="nav-session-menu-copy-id"
data-session-id={session.sessionId}
>
<Copy size={13} />
<span>{t('nav.sessions.copySessionId')}</span>
</button>
<button
type="button"
className="bitfun-nav-panel__inline-item-menu-item"
Expand All @@ -1194,6 +1225,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
setScheduledJobsSessionId(session.sessionId);
}}
disabled={!workspacePath}
data-testid="nav-session-menu-scheduled-jobs"
data-session-id={session.sessionId}
>
<Clock3 size={13} />
<span>{t('nav.scheduledJobs.open')}</span>
Expand All @@ -1202,6 +1235,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
type="button"
className="bitfun-nav-panel__inline-item-menu-item"
onClick={e => { setOpenMenuSessionId(null); void handleArchive(e, session.sessionId); }}
data-testid="nav-session-menu-archive"
data-session-id={session.sessionId}
>
<Archive size={13} />
<span>{t('nav.sessions.archive')}</span>
Expand All @@ -1210,6 +1245,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
type="button"
className="bitfun-nav-panel__inline-item-menu-item is-danger"
onClick={e => { setOpenMenuSessionId(null); void handleDelete(e, session.sessionId); }}
data-testid="nav-session-menu-delete"
data-session-id={session.sessionId}
>
<Trash2 size={13} />
<span>{t('nav.sessions.delete')}</span>
Expand Down
14 changes: 5 additions & 9 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1828,7 +1828,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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;
Expand All @@ -1852,7 +1853,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
// 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',
Expand All @@ -1866,7 +1867,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}

// 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',
Expand Down Expand Up @@ -2985,11 +2986,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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;
}

Expand Down
22 changes: 22 additions & 0 deletions src/web-ui/src/flow_chat/components/RichTextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RichTextInput
value=""
onChange={onChange}
contexts={emptyContexts}
onRemoveContext={() => {}}
/>
);
});

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<HarnessHandle>();
const editor = await renderHarness(harnessRef);
Expand Down
10 changes: 9 additions & 1 deletion src/web-ui/src/flow_chat/components/RichTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -448,7 +452,11 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
};

internalRef.current.childNodes.forEach(traverse);
return sanitizeText(text).trim();
const sanitizedText = sanitizeText(text);
const extractedText = sanitizedText.startsWith('/')
? trimEdgeLineBreaks(sanitizedText)
: sanitizedText.trim();
return extractedText;
}, [internalRef]);

// Detect @ mention plus inline / and $ triggers near the caret.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
archiveChatSession,
createChatSession,
deleteChatSession,
ensureBackendSession,
preloadHistoricalSessionForOpen,
Expand Down Expand Up @@ -151,6 +152,36 @@ function createContext(
};
const flowChatStore = {
getState: () => state,
createSession: vi.fn((
sessionId: string,
config?: Record<string, unknown>,
_unused?: unknown,
title?: string,
_maxContextTokens?: number,
agentType?: string,
workspacePath?: string,
remoteConnectionId?: string,
remoteSshHost?: string,
) => {
const nextSession = createSession({
sessionId,
title: title ?? sessionId,
isHistorical: false,
historyState: 'ready',
config: {
agentType: agentType ?? (config?.agentType as string | undefined) ?? 'agentic',
},
mode: agentType ?? 'agentic',
workspacePath: workspacePath ?? (config?.workspacePath as string | undefined) ?? session.workspacePath,
remoteConnectionId,
remoteSshHost,
});
state = {
...state,
sessions: new Map(state.sessions).set(sessionId, nextSession),
activeSessionId: sessionId,
};
}),
switchSession: vi.fn((sessionId: string) => {
state = { ...state, activeSessionId: sessionId };
}),
Expand Down Expand Up @@ -247,6 +278,45 @@ describe('resolveAgentTypeForSessionCreation', () => {
});
});

describe('createChatSession', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('dedupes concurrent creates before model config loading resolves', async () => {
// This keeps the first create suspended in the model config path while the
// second create enters with the same creation key.
const modelConfig = createDeferred<Record<string, unknown>>();
configApiMocks.getConfig.mockImplementation(async (key: string) => {
if (key === 'chat.default_mode') {
return null;
}
await modelConfig.promise;
return key === 'ai.models' ? [] : {};
});
agentApiMocks.getAvailableModes.mockResolvedValue([{ id: 'agentic' }]);
agentApiMocks.createSession.mockResolvedValue({ sessionId: 'created-1' });

const { context } = createContext(createSession({
workspacePath: '/home/wsp/projects/Test',
}));

const firstCreate = createChatSession(context, { workspacePath: '/home/wsp/projects/Test' }, 'agentic');
const secondCreate = createChatSession(context, { workspacePath: '/home/wsp/projects/Test' }, 'agentic');

await Promise.resolve();
expect(agentApiMocks.createSession).not.toHaveBeenCalled();

modelConfig.resolve({});
await expect(Promise.all([firstCreate, secondCreate])).resolves.toEqual([
'created-1',
'created-1',
]);

expect(agentApiMocks.createSession).toHaveBeenCalledTimes(1);
});
});

describe('SessionModule historical session coordination', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,31 +548,33 @@ export async function createChatSession(
return pendingCreation;
}

const sameModeCount = getNextDefaultSessionTitleCount(
context.flowChatStore.getState().sessions.values(),
{
mode: sessionMode,
workspaceId: workspace?.id,
workspacePath,
remoteConnectionId,
remoteSshHost,
},
);
const titleDescriptor = createDefaultSessionTitleDescriptor(
sessionMode,
sameModeCount,
(key, options) => i18nService.t(key, options),
);
const sessionName = titleDescriptor.text;

const maxContextTokens = await getModelMaxTokens(config.modelName, agentType);
// Register the pending promise before any async work below. Remote workspace
// activation can rerun initialization while model config is still loading.
const createPromise = Promise.resolve().then(async () => {
const sameModeCount = getNextDefaultSessionTitleCount(
context.flowChatStore.getState().sessions.values(),
{
mode: sessionMode,
workspaceId: workspace?.id,
workspacePath,
remoteConnectionId,
remoteSshHost,
},
);
const titleDescriptor = createDefaultSessionTitleDescriptor(
sessionMode,
sameModeCount,
(key, options) => i18nService.t(key, options),
);
const sessionName = titleDescriptor.text;

const maxContextTokens = await getModelMaxTokens(config.modelName, agentType);

const mergedConfig: SessionConfig = {
...config,
workspaceId: workspace?.id ?? config.workspaceId,
};
const mergedConfig: SessionConfig = {
...config,
workspaceId: workspace?.id ?? config.workspaceId,
};

const createPromise = (async () => {
const response = await agentAPI.createSession({
sessionName,
agentType,
Expand Down Expand Up @@ -606,7 +608,7 @@ export async function createChatSession(
);

return response.sessionId;
})();
});

pendingSessionCreations.set(creationKey, createPromise);
try {
Expand Down
Loading
Loading