fix: keep question order and streamline contextual help
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Keep interactive questions and answered cards at their original position in conversation history, including remote reception, recovery, and reopening. Preserve legacy history placement without a database migration. Move supplementary guidance into shared accessible contextual-help popovers across pages and settings. Add keyboard, modal, accessibility, and Electron regressions, plus bounded opt-in live canvas-analysis validation. Remove unused Agent, Runtime, and in-memory graph-search helpers, obsolete mocks and browser copy; reuse the existing package-size formatter. Keep production execution paths, protocol schemas, and released data migrations. Validation: targeted cleanup tests, typecheck, lint, shared Linux Host attach/read/re-attach, and added help/Electron checks passed. Final full-suite verification passed 4,858 tests with 67 skipped and zero failures (412 files passed, 9 skipped). Focused help checks passed 54 tests, including Electron style comparisons and Chromium accessibility descriptions. Details and validation boundaries are recorded in feature docs. Release note: 修复交互式问答在回答后排到后续输出末尾的问题,重新打开会话仍保留提问位置。页面和设置中的补充说明改为标题或字段旁的按需帮助,支持鼠标与键盘操作。
This commit is contained in:
@@ -77,6 +77,17 @@ function makeTokenUsage(): TokenUsageSummary {
|
||||
}
|
||||
|
||||
describe('ActivityPanel', () => {
|
||||
it('opens timeline guidance separately from tab navigation', () => {
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
|
||||
const help = screen.getByRole('button', { name: '活动时间线' })
|
||||
expect(help.closest('[role="tab"]')).toBeNull()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
fireEvent.click(help)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('所有轨道共享同一执行顺序')
|
||||
expect(screen.getByRole('tab', { name: '活动时间线' })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
@@ -106,6 +117,7 @@ describe('ActivityPanel', () => {
|
||||
screen.getByRole('heading', { level: 1, name: 'Run history' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('RUN HISTORY')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run history' }))
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.'
|
||||
@@ -705,8 +717,9 @@ describe('ActivityPanel', () => {
|
||||
).getByText('G')
|
||||
).toBeInTheDocument()
|
||||
expect(within(timeline).getByText('项目:项目甲')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '活动时间线' }))
|
||||
expect(
|
||||
within(timeline).getByText(
|
||||
screen.getByText(
|
||||
'所有轨道共享同一执行顺序,节点按发生时间依次展开;点击节点可查看身份和活动详情。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Activity, Trash2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
AssistantProject,
|
||||
TokenUsageSummary
|
||||
@@ -694,7 +695,7 @@ export function ActivityPanel({
|
||||
className="activity-panel"
|
||||
>
|
||||
<PageHeader
|
||||
description={t('header.description')}
|
||||
help={t('header.description')}
|
||||
headingId="activity-panel-title"
|
||||
icon={<Activity size={20} />}
|
||||
scope={{ kind: 'all-projects' }}
|
||||
@@ -822,9 +823,10 @@ export function ActivityPanel({
|
||||
aria-label={t('timeline.ariaLabel')}
|
||||
className="activity-tracks"
|
||||
>
|
||||
<p className="activity-tracks__description">
|
||||
{t('timeline.description')}
|
||||
</p>
|
||||
<div className="inline-help-label">
|
||||
<strong>{t('tabs.timeline')}</strong>
|
||||
<InlineHelp label={t('tabs.timeline')}>{t('timeline.description')}</InlineHelp>
|
||||
</div>
|
||||
<ul
|
||||
aria-label={t('timeline.legendAriaLabel')}
|
||||
className="activity-tracks__legend"
|
||||
|
||||
@@ -1620,7 +1620,12 @@ describe("App", () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([{
|
||||
...saved, updatedAt: saved.updatedAt + 120_000,
|
||||
messages: saved.messages.map((message) => message.role === "assistant"
|
||||
? { ...message, state: "complete", content: "Persisted terminal result" } : message),
|
||||
? {
|
||||
...message, state: "complete", content: "Persisted terminal result",
|
||||
blocks: [...(message.blocks ?? []), {
|
||||
id: crypto.randomUUID(), type: "text" as const, content: "Persisted terminal result",
|
||||
}],
|
||||
} : message),
|
||||
}]);
|
||||
act(() => conversationQueueChangeListener?.(request.conversationId!));
|
||||
await screen.findByText("Persisted terminal result");
|
||||
@@ -11819,6 +11824,66 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps interactive questions in event order after a late answer response and history reload", async () => {
|
||||
const response = deferred<void>();
|
||||
vi.mocked(api.agent.respondQuestion).mockReturnValueOnce(response.promise);
|
||||
const view = render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "Question order regression" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
const question: Extract<AgentEvent, { type: "question" }> = {
|
||||
requestId, type: "question", questionId: "ordered-question",
|
||||
questions: [{
|
||||
header: "休息日", question: "你想怎样安排?",
|
||||
options: [{ label: "探索美食", description: "" }], multiple: false, custom: false,
|
||||
}],
|
||||
};
|
||||
act(() => {
|
||||
agentListener?.({ requestId, type: "text", delta: "Before question" });
|
||||
agentListener?.(question);
|
||||
agentListener?.(question);
|
||||
});
|
||||
expect(document.querySelectorAll(".agent-question-card")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "探索美食" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
await waitFor(() => expect(api.agent.respondQuestion).toHaveBeenCalledWith("ordered-question", [["探索美食"]]));
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId, type: "tool", callId: "after-question", name: "after_question",
|
||||
state: "completed", summary: "after_question",
|
||||
});
|
||||
agentListener?.({ requestId, type: "text", delta: "Selection received" });
|
||||
agentListener?.({ requestId, type: "done" });
|
||||
});
|
||||
await act(async () => response.resolve());
|
||||
const assertOrder = () => {
|
||||
const card = screen.getByText("问题与回答").closest(".agent-question-review")!;
|
||||
expect(card.closest(".message-blocks")).not.toBeNull();
|
||||
expect(screen.getByText("Before question").compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
|
||||
expect(card.compareDocumentPosition(screen.getByText("Selection received")) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
|
||||
};
|
||||
await waitFor(assertOrder);
|
||||
let persisted: ConversationSnapshot | undefined;
|
||||
await waitFor(() => {
|
||||
const saved = vi.mocked(api.conversations.saveLocal).mock.calls
|
||||
.flatMap(([batch]) => batch)
|
||||
.filter(item => item.messages.some(message => message.answeredQuestions?.some(answer => answer.questionId === question.questionId)))
|
||||
.at(-1);
|
||||
expect(saved).toBeDefined();
|
||||
const message = saved!.messages.find(message => message.answeredQuestions?.length)!;
|
||||
expect(message.blocks?.map(block => block.type)).toEqual(["text", "question", "tool", "text"]);
|
||||
expect(message.blocks?.filter(block => block.type === "question")).toHaveLength(1);
|
||||
persisted = { ...saved!.header, messages: saved!.messages };
|
||||
});
|
||||
view.unmount();
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([persisted!]);
|
||||
render(<App />);
|
||||
await waitFor(assertOrder);
|
||||
});
|
||||
|
||||
it("queues concurrent task questions by ID without losing drafts on duplicates or failed replies", async () => {
|
||||
let reject!: (error: Error) => void;
|
||||
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((_resolve, no) => { reject = no; }));
|
||||
|
||||
@@ -206,6 +206,7 @@ import {
|
||||
} from "./CustomTaskDialog";
|
||||
import { ConversationTaskStrip } from "./ConversationTaskStrip";
|
||||
import { RuntimeChecklistStrip } from "./RuntimeChecklistStrip";
|
||||
import { appendConversationQuestionBlock } from "../../shared/conversation-question-blocks";
|
||||
import { ConversationInputQueue } from "./ConversationInputQueue";
|
||||
import { OverflowMarquee } from "./OverflowMarquee";
|
||||
import { findTaskSchedule } from "./TaskScheduleActions";
|
||||
@@ -5082,6 +5083,7 @@ function App(): React.JSX.Element {
|
||||
return {
|
||||
...message,
|
||||
status: undefined,
|
||||
blocks: appendConversationQuestionBlock(message.blocks, event.questionId),
|
||||
pendingQuestions: [...(message.pendingQuestions ?? []), event],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import {
|
||||
applicationSettingsSchema,
|
||||
defaultApplicationNavigation,
|
||||
@@ -40,9 +41,34 @@ const pinnedSettings = {
|
||||
pinned: { 'magic-notes': true, 'local-inference': true },
|
||||
},
|
||||
}
|
||||
afterEach(cleanup)
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await changeUiLocale('zh-CN')
|
||||
})
|
||||
|
||||
describe('Application Center', () => {
|
||||
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
|
||||
await changeUiLocale(locale)
|
||||
const onUpdate = vi.fn(async () => true)
|
||||
const { container } = render(<ApplicationSettingsView id="magic-notes" settings={settings} pending={false} onUpdate={onUpdate} />)
|
||||
const pinLabel = locale === 'zh-CN' ? '常驻左侧菜单' : 'Pin to sidebar'
|
||||
const explanation = locale === 'zh-CN' ? '常驻只控制左侧捷径。' : 'Pinning only controls the sidebar shortcut.'
|
||||
const consequence = locale === 'zh-CN' ? /不停止已有请求或后台服务/ : /without stopping existing requests or background services/
|
||||
expect(screen.getByText(explanation)).not.toBeVisible()
|
||||
expect(screen.getByText(consequence)).toBeVisible()
|
||||
const help = screen.getByRole('button', { name: pinLabel })
|
||||
expect(container.querySelector('label .inline-help, button .inline-help, summary .inline-help')).toBeNull()
|
||||
const pin = screen.getByRole('switch', { name: pinLabel })
|
||||
expect(pin).toHaveAccessibleDescription(explanation)
|
||||
const checked = (pin as HTMLInputElement).checked
|
||||
fireEvent.click(help)
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect((pin as HTMLInputElement).checked).toBe(checked)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(explanation)
|
||||
expect(screen.getByRole('tooltip')).not.toHaveTextContent(consequence)
|
||||
expect(pin).toHaveAccessibleDescription(explanation)
|
||||
})
|
||||
|
||||
it('returns from compact settings through header navigation without losing the search', () => {
|
||||
render(<ApplicationCenter {...props()} />)
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '笔记' } })
|
||||
@@ -282,13 +308,17 @@ describe('Application Center', () => {
|
||||
const pageCount = screen.getByRole('combobox', { name: '发送画布页数' })
|
||||
expect(pageCount).toHaveValue('1')
|
||||
expect(within(pageCount).getAllByRole('option')).toHaveLength(8)
|
||||
expect(pageCount).toHaveAccessibleDescription(expect.stringContaining('不是笔记记录数量'))
|
||||
expect(pageCount.parentElement).toHaveClass('field')
|
||||
const help = screen.getByRole('button', { name: '发送画布页数' })
|
||||
expect(help).toHaveClass('icon-button')
|
||||
expect(help).toHaveAccessibleDescription(help.getAttribute('title')!)
|
||||
expect(help).toHaveAttribute('title', expect.stringContaining('不是笔记记录数量'))
|
||||
expect(document.getElementById('magic-note-canvas-page-count-help')).toHaveClass('sr-only')
|
||||
expect(help).toHaveClass('inline-help')
|
||||
expect(help).not.toHaveAttribute('title')
|
||||
expect(help.closest('label, summary')).toBeNull()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
fireEvent.click(help)
|
||||
expect(pageCount).toHaveValue('1')
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(pageCount).toHaveAccessibleDescription(expect.stringContaining('不是笔记记录数量'))
|
||||
expect(screen.getByRole('tooltip')).toHaveAttribute('id', 'magic-note-canvas-page-count-help')
|
||||
fireEvent.change(pageCount, { target: { value: '8' } })
|
||||
expect(onUpdate).toHaveBeenLastCalledWith({ magicNoteCanvasPageCount: 8 })
|
||||
fireEvent.click(screen.getByRole('switch', { name: '显示未完成待办数量' }))
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
CircleHelp,
|
||||
Cpu,
|
||||
HeartPulse,
|
||||
Library,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import {
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings,
|
||||
@@ -117,24 +117,28 @@ export function ApplicationSettingsView({
|
||||
/>
|
||||
<span>{t('applications.enable')}</span>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={navigation.pinned[id]}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
void onUpdate({
|
||||
applicationNavigation: {
|
||||
...navigation,
|
||||
pinned: { ...navigation.pinned, [id]: event.target.checked },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{t('applications.pin')}</span>
|
||||
</label>
|
||||
<p className="settings-notice">{t('applications.enableHelp')}</p>
|
||||
<div className="inline-help-label">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={navigation.pinned[id]}
|
||||
aria-describedby="application-pin-help"
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
void onUpdate({
|
||||
applicationNavigation: {
|
||||
...navigation,
|
||||
pinned: { ...navigation.pinned, [id]: event.target.checked },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{t('applications.pin')}</span>
|
||||
</label>
|
||||
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
|
||||
</div>
|
||||
<p className="settings-notice">{t('applications.disableHelp')}</p>
|
||||
{id === 'magic-notes' && (
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
@@ -161,12 +165,10 @@ export function ApplicationSettingsView({
|
||||
<div className="platform-feature-option application-settings__compact-option">
|
||||
<div className="application-settings__label-with-help">
|
||||
<label htmlFor="magic-note-canvas-page-count">{t('platformFeatures.magicNotes.canvasPageCount', { ns: 'settingsSections' })}</label>
|
||||
<button type="button" className="icon-button"
|
||||
aria-label={t('platformFeatures.magicNotes.canvasPageCount', { ns: 'settingsSections' })}
|
||||
aria-describedby="magic-note-canvas-page-count-help"
|
||||
title={t('platformFeatures.magicNotes.canvasPageCountHelp', { ns: 'settingsSections' })}>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<InlineHelp label={t('platformFeatures.magicNotes.canvasPageCount', { ns: 'settingsSections' })}
|
||||
id="magic-note-canvas-page-count-help">
|
||||
{t('platformFeatures.magicNotes.canvasPageCountHelp', { ns: 'settingsSections' })}
|
||||
</InlineHelp>
|
||||
</div>
|
||||
<div className="field">
|
||||
<select id="magic-note-canvas-page-count" disabled={pending} value={settings.magicNoteCanvasPageCount ?? 1}
|
||||
@@ -176,9 +178,6 @@ export function ApplicationSettingsView({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<span id="magic-note-canvas-page-count-help" className="sr-only">
|
||||
{t('platformFeatures.magicNotes.canvasPageCountHelp', { ns: 'settingsSections' })}
|
||||
</span>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentMode', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ImagePlus, RotateCcw, Save, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import {
|
||||
brandingLogoMaximumBytes,
|
||||
brandingNameMaximumLength,
|
||||
@@ -201,14 +202,17 @@ export function BrandingSettingsSection({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<ImagePlus aria-hidden="true" size={13} />
|
||||
{t('appearance.branding.logo.select')}
|
||||
</button>
|
||||
<span className="inline-help-label">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<ImagePlus aria-hidden="true" size={13} />
|
||||
{t('appearance.branding.logo.select')}
|
||||
</button>
|
||||
<InlineHelp label={t('appearance.branding.logo.select')}>{t('appearance.branding.logo.help')}</InlineHelp>
|
||||
</span>
|
||||
{draft.logoDataUrl && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
@@ -227,7 +231,6 @@ export function BrandingSettingsSection({
|
||||
{t('appearance.branding.logo.useDefault')}
|
||||
</button>
|
||||
)}
|
||||
<small>{t('appearance.branding.logo.help')}</small>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
repairChannelRuntimeSelection
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
||||
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
|
||||
export function channelProjectDraft(
|
||||
project: ProjectCreateInput,
|
||||
@@ -107,22 +108,23 @@ export function ChannelProjectSettingsFields({
|
||||
selection={value.runtimeSelection}
|
||||
selectionMode="channel"
|
||||
/>
|
||||
<ProjectWorkModeFields
|
||||
<fieldset className="project-work-mode">
|
||||
<legend><span className="inline-help-label">{t('channels.project.defaultMode')}<InlineHelp label={t('channels.project.defaultMode')}>{t('channels.project.overrideHelp')}</InlineHelp></span></legend>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('channels.project.defaultModeAriaLabel', {
|
||||
name: value.name
|
||||
})}
|
||||
disabled={disabled}
|
||||
help={t('channels.project.overrideHelp')}
|
||||
labels={{
|
||||
ask: t('channels.project.modes.ask'),
|
||||
execute: t('channels.project.modes.execute')
|
||||
}}
|
||||
legend={t('channels.project.defaultMode')}
|
||||
options={[
|
||||
{ value: 'ask', label: t('channels.project.modes.ask') },
|
||||
{ value: 'execute', label: t('channels.project.modes.execute') }
|
||||
]}
|
||||
onChange={(defaultWorkMode) =>
|
||||
onChange({ ...value, defaultWorkMode })
|
||||
}
|
||||
value={value.defaultWorkMode}
|
||||
/>
|
||||
</fieldset>
|
||||
<p className="channel-project-settings__risk">
|
||||
{value.defaultWorkMode === 'execute'
|
||||
? t('channels.project.executeRisk')
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { createPortal } from 'react-dom'
|
||||
import QRCode from 'qrcode'
|
||||
import type {
|
||||
@@ -687,7 +688,10 @@ function WeixinChannelEditor({
|
||||
<article className="capability-card channel-settings-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('channels.tabs.weixin')}</strong>
|
||||
<span className="inline-help-label">
|
||||
<strong>{t('channels.tabs.weixin')}</strong>
|
||||
<InlineHelp label={t('channels.tabs.weixin')} id="channel-weixin-behavior-help">{t('channels.weixin.behaviorHelp')}</InlineHelp>
|
||||
</span>
|
||||
<small>
|
||||
{settings.bindingConfigured
|
||||
? t('channels.weixin.bindingSaved', {
|
||||
@@ -712,6 +716,7 @@ function WeixinChannelEditor({
|
||||
checked={enabled}
|
||||
disabled={!settings.bindingConfigured}
|
||||
id="channel-weixin-enabled"
|
||||
aria-describedby="channel-weixin-behavior-help"
|
||||
onChange={(event) =>
|
||||
onEnabledChange(event.target.checked)
|
||||
}
|
||||
@@ -755,9 +760,6 @@ function WeixinChannelEditor({
|
||||
{t('channels.weixin.disconnectHelp')}
|
||||
</small>
|
||||
)}
|
||||
<small>
|
||||
{t('channels.weixin.behaviorHelp')}
|
||||
</small>
|
||||
|
||||
</article>
|
||||
<ChannelProjectCard
|
||||
|
||||
@@ -80,6 +80,84 @@ function createMessages(): Message[] {
|
||||
}
|
||||
|
||||
describe('ChatTimeline', () => {
|
||||
it('keeps a question before later tools and text when its answer arrives and history reopens', () => {
|
||||
const props = {
|
||||
artifactById: new Map(), conversationId: 'question-order',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
|
||||
}
|
||||
const question = {
|
||||
requestId: 'request', type: 'question' as const, questionId: 'question-1',
|
||||
questions: [{
|
||||
header: '休息日', question: '你想怎样安排?',
|
||||
options: [{ label: '探索美食', description: '' }], multiple: false, custom: false
|
||||
}]
|
||||
}
|
||||
const message: Message = {
|
||||
id: 'ordered-message', role: 'assistant', content: '提问前\n选择结果已收到',
|
||||
createdAt: 0, state: 'streaming', pendingQuestions: [question],
|
||||
blocks: [
|
||||
{ id: 'before', type: 'text', content: '提问前' },
|
||||
{ id: 'question', type: 'question', questionId: question.questionId },
|
||||
{ id: 'tool', type: 'tool', tool: { callId: 'call-1', name: 'after_question', state: 'completed', summary: 'after_question' } },
|
||||
{ id: 'after', type: 'text', content: '选择结果已收到' }
|
||||
]
|
||||
}
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
const assertOrder = (questionSelector: string) => {
|
||||
const blocks = view.container.querySelector('.message-blocks')!
|
||||
const card = blocks.querySelector(questionSelector)!
|
||||
expect(card).not.toBeNull()
|
||||
expect(screen.getByText('提问前').compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(card.compareDocumentPosition(blocks.querySelector('.tool-execution-list')!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(card.compareDocumentPosition(screen.getByText('选择结果已收到')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
}
|
||||
assertOrder('.agent-question-card')
|
||||
const completed: Message = {
|
||||
...message, state: 'complete', pendingQuestions: undefined,
|
||||
answeredQuestions: [{ questionId: question.questionId, questions: [{
|
||||
...question.questions[0]!, answer: ['探索美食']
|
||||
}] }]
|
||||
}
|
||||
view.rerender(<ChatTimeline {...props} messages={[completed]} />)
|
||||
assertOrder('.agent-question-review')
|
||||
expect(screen.getAllByText('问题与回答')).toHaveLength(1)
|
||||
view.unmount()
|
||||
const reopened = render(<ChatTimeline {...props} messages={[JSON.parse(JSON.stringify(completed))]} />)
|
||||
const card = reopened.container.querySelector('.agent-question-review')!
|
||||
expect(card.closest('.message-blocks')).not.toBeNull()
|
||||
expect(card.compareDocumentPosition(screen.getByText('选择结果已收到')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
|
||||
it('uses question marker order rather than answer-array order and retains unanchored legacy answers', () => {
|
||||
const review = (questionId: string) => ({
|
||||
questionId, skipped: true,
|
||||
questions: [{ header: questionId, question: `${questionId}?`, options: [], multiple: false, custom: true }]
|
||||
})
|
||||
const view = render(<ChatTimeline
|
||||
artifactById={new Map()} conversationId="multiple-questions"
|
||||
hiddenMessageCount={0} isUnusedConversation={false} locale="zh-CN"
|
||||
messageStartIndex={0} {...callbacks} retryContent="" totalMessageCount={1}
|
||||
messages={[{
|
||||
id: 'message', role: 'assistant', content: 'Between', state: 'complete', createdAt: 0,
|
||||
blocks: [
|
||||
{ id: 'q1', type: 'question', questionId: 'first' },
|
||||
{ id: 'text', type: 'text', content: 'Between' },
|
||||
{ id: 'q2', type: 'question', questionId: 'second' }
|
||||
],
|
||||
answeredQuestions: [review('second'), review('first'), review('legacy')]
|
||||
}]}
|
||||
/>)
|
||||
const cards = [...view.container.querySelectorAll('.agent-question-review')]
|
||||
expect(cards).toHaveLength(3)
|
||||
expect(cards.map(card => card.querySelector('.agent-question-review__question span')!.textContent)).toEqual([
|
||||
'first', 'second', 'legacy'
|
||||
])
|
||||
expect(cards[0]!.compareDocumentPosition(screen.getByText('Between')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(screen.getByText('Between').compareDocumentPosition(cards[1]!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(cards[2]!.closest('.message-blocks')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['read', 'OpenCode 工具:read', false],
|
||||
['read', 'Continue 工具:read', false],
|
||||
|
||||
@@ -112,12 +112,21 @@ type MessageBlockRenderItem =
|
||||
id: string
|
||||
childTaskIds: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'question'
|
||||
id: string
|
||||
questionId: string
|
||||
}
|
||||
|
||||
function groupMessageBlocks(
|
||||
blocks: ConversationMessageBlock[]
|
||||
): MessageBlockRenderItem[] {
|
||||
const items: MessageBlockRenderItem[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'question') {
|
||||
items.push({ kind: 'question', id: block.id, questionId: block.questionId })
|
||||
continue
|
||||
}
|
||||
if (block.type === 'subagent') {
|
||||
const previous = items.at(-1)
|
||||
if (previous?.kind === 'subagents') {
|
||||
@@ -437,7 +446,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
? { ...tool, state: subagent.state === 'cancelled' ? 'cancelled' : 'interrupted' }
|
||||
: tool
|
||||
)} />
|
||||
) : item.kind === 'subagents' ? null
|
||||
) : item.kind === 'subagents' || item.kind === 'question' ? null
|
||||
: item.block.type === 'reasoning' ? (
|
||||
<MessageReasoning
|
||||
key={item.block.id}
|
||||
@@ -643,6 +652,52 @@ function ChatMessageRowView({
|
||||
const question = message.pendingQuestions?.[0]
|
||||
const questionTask = question?.childTaskId ? subagentsById.get(question.childTaskId) : undefined
|
||||
const questionFormId = `agent-question-${message.id}`
|
||||
const orderedQuestionIds = useMemo(() => new Set(
|
||||
message.blocks?.filter(block => block.type === 'question').map(block => block.questionId)
|
||||
), [message.blocks])
|
||||
const answeredQuestionsById = useMemo(() => new Map(
|
||||
message.answeredQuestions?.map(review => [review.questionId, review])
|
||||
), [message.answeredQuestions])
|
||||
const renderQuestion = (questionId: string): React.JSX.Element | null => {
|
||||
if (question?.questionId === questionId) {
|
||||
return (
|
||||
<AgentQuestionCard
|
||||
id={questionFormId}
|
||||
pendingCount={message.pendingQuestions?.length}
|
||||
taskTitle={questionTask?.reason ??
|
||||
(questionTask && 'expertName' in questionTask ? questionTask.expertName : undefined) ?? message.task?.title}
|
||||
key={questionId}
|
||||
onReject={() => onRespondQuestion(conversationId, message.id, questionId)}
|
||||
onSubmit={(answers) => onRespondQuestion(conversationId, message.id, questionId, answers)}
|
||||
value={question}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const review = answeredQuestionsById.get(questionId)
|
||||
if (!review) return null
|
||||
return (
|
||||
<div className="agent-question-review" key={questionId}>
|
||||
<header>
|
||||
<CircleHelp aria-hidden="true" size={16} />
|
||||
<strong>{t('chat.questionReview.title')}</strong>
|
||||
</header>
|
||||
{review.questions.map((item, index) => (
|
||||
<div className="agent-question-review__item" key={`${questionId}:${index}`}>
|
||||
<p className="agent-question-review__question">
|
||||
<span>{item.header}</span>
|
||||
{item.question}
|
||||
</p>
|
||||
<p className="agent-question-review__answer">
|
||||
<span>{t('chat.questionReview.answerLabel')}</span>
|
||||
{review.skipped || !item.answer?.length
|
||||
? t('chat.questionReview.skipped')
|
||||
: item.answer.join('、')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const activeTool = message.tools?.find((tool) =>
|
||||
tool.state === 'pending' || tool.state === 'running')
|
||||
const activeSubagent = message.subagents?.some((subagent) =>
|
||||
@@ -788,7 +843,8 @@ function ChatMessageRowView({
|
||||
{message.blocks && message.blocks.length > 0 ? (
|
||||
<div className="message-blocks">
|
||||
{groupMessageBlocks(message.blocks).map((item) =>
|
||||
item.kind === 'tools' ? (
|
||||
item.kind === 'question' ? renderQuestion(item.questionId)
|
||||
: item.kind === 'tools' ? (
|
||||
<ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} />
|
||||
) : item.kind === 'subagents' ? (
|
||||
<SubagentStatusList
|
||||
@@ -1149,58 +1205,9 @@ function ChatMessageRowView({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{question && (
|
||||
<AgentQuestionCard
|
||||
id={questionFormId}
|
||||
pendingCount={message.pendingQuestions?.length}
|
||||
taskTitle={questionTask?.reason ??
|
||||
(questionTask && 'expertName' in questionTask ? questionTask.expertName : undefined) ?? message.task?.title}
|
||||
key={question.questionId}
|
||||
onReject={() =>
|
||||
onRespondQuestion(
|
||||
conversationId,
|
||||
message.id,
|
||||
question.questionId
|
||||
)
|
||||
}
|
||||
onSubmit={(answers) =>
|
||||
onRespondQuestion(
|
||||
conversationId,
|
||||
message.id,
|
||||
question.questionId,
|
||||
answers
|
||||
)
|
||||
}
|
||||
value={question}
|
||||
/>
|
||||
)}
|
||||
{message.answeredQuestions?.map((review) => (
|
||||
<div className="agent-question-review" key={review.questionId}>
|
||||
<header>
|
||||
<CircleHelp aria-hidden="true" size={16} />
|
||||
<strong>{t('chat.questionReview.title')}</strong>
|
||||
</header>
|
||||
{review.questions.map((question, index) => (
|
||||
<div
|
||||
className="agent-question-review__item"
|
||||
key={`${review.questionId}:${index}`}
|
||||
>
|
||||
<p className="agent-question-review__question">
|
||||
<span>{question.header}</span>
|
||||
{question.question}
|
||||
</p>
|
||||
<p className="agent-question-review__answer">
|
||||
<span>{t('chat.questionReview.answerLabel')}</span>
|
||||
{review.skipped ||
|
||||
!question.answer ||
|
||||
question.answer.length === 0
|
||||
? t('chat.questionReview.skipped')
|
||||
: question.answer.join('、')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{question && !orderedQuestionIds.has(question.questionId) && renderQuestion(question.questionId)}
|
||||
{message.answeredQuestions?.filter(review => !orderedQuestionIds.has(review.questionId))
|
||||
.map(review => renderQuestion(review.questionId))}
|
||||
{message.role === 'assistant' && message.imageContextNotice && (
|
||||
<p className="message__image-context-note">
|
||||
{t(`chat.images.contextNotice.${message.imageContextNotice}`)}
|
||||
|
||||
@@ -26,6 +26,23 @@ function futureLocalDateTime(): string {
|
||||
}
|
||||
|
||||
describe('CustomTaskDialog', () => {
|
||||
it('keeps descriptions accessible and opens help outside field labels', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CustomTaskDialog currentConversationAvailable={false} onClose={onClose} onCreate={vi.fn()} projectName="Project" workspaceLabel="Workspace" />)
|
||||
const dialog = screen.getByRole('dialog', { name: '新建定制任务' })
|
||||
expect(document.getElementById(dialog.getAttribute('aria-describedby')!)).toHaveClass('sr-only')
|
||||
const name = screen.getByRole('textbox', { name: '任务名称' })
|
||||
expect(document.getElementById(name.getAttribute('aria-describedby')!)).toHaveClass('sr-only')
|
||||
const help = screen.getByRole('button', { name: '任务名称' })
|
||||
expect(help.closest('label')).toBeNull()
|
||||
fireEvent.click(help)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(document.getElementById('custom-task-name-help')!.textContent!)
|
||||
fireEvent.keyDown(help, { key: 'Escape' })
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(dialog).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps dialog spacing and form controls inside their layout bounds', () => {
|
||||
expect(stylesheet).toMatch(/--space-5:\s*20px;/u)
|
||||
expect(stylesheet).toMatch(
|
||||
@@ -72,7 +89,7 @@ describe('CustomTaskDialog', () => {
|
||||
expect(screen.getByLabelText('关联会话')).toHaveValue('current')
|
||||
expect(screen.getByLabelText('任务内容')).toHaveFocus()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('任务名称'), {
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '任务名称' }), {
|
||||
target: { value: '每周项目总结' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), {
|
||||
@@ -190,7 +207,7 @@ describe('CustomTaskDialog', () => {
|
||||
expect(screen.queryByRole('option', { name: '当前项目会话' })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
|
||||
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: ' 自定义名称 ' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '任务名称' }), { target: { value: ' 自定义名称 ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ conversationId: otherId, title: '自定义名称' }),
|
||||
@@ -256,7 +273,7 @@ describe('CustomTaskDialog', () => {
|
||||
const create = screen.getByRole('button', { name: '创建任务' })
|
||||
create.focus()
|
||||
fireEvent.keyDown(create, { key: 'Tab' })
|
||||
expect(screen.getByRole('button', { name: '关闭新建定制任务' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '新建定制任务' })).toHaveFocus()
|
||||
unmount()
|
||||
expect(opener).toHaveFocus()
|
||||
opener.remove()
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import './custom-task-dialog.css'
|
||||
|
||||
export type CustomTaskDestination = 'current' | 'new'
|
||||
@@ -175,10 +176,13 @@ export function CustomTaskDialog({
|
||||
<ClockFading aria-hidden="true" size={14} />
|
||||
{t('customTask.eyebrow')}
|
||||
</span>
|
||||
<h2 id="custom-task-title">{t('customTask.title')}</h2>
|
||||
<p id="custom-task-description">
|
||||
<div className="inline-help-label">
|
||||
<h2 id="custom-task-title">{t('customTask.title')}</h2>
|
||||
<InlineHelp label={t('customTask.title')}>{t('customTask.description')}</InlineHelp>
|
||||
</div>
|
||||
<span className="sr-only" id="custom-task-description">
|
||||
{t('customTask.description')}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('customTask.close')}
|
||||
@@ -218,9 +222,14 @@ export function CustomTaskDialog({
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="custom-task-dialog__field">
|
||||
<span id="custom-task-name-label">{t('customTask.fields.name')}</span>
|
||||
<div className="custom-task-dialog__field">
|
||||
<div className="inline-help-label">
|
||||
<label htmlFor="custom-task-name" id="custom-task-name-label">{t('customTask.fields.name')}</label>
|
||||
<small>{t('applications.optional')}</small>
|
||||
<InlineHelp label={t('customTask.fields.name')}>{t('customTask.nameHelp')}</InlineHelp>
|
||||
</div>
|
||||
<input
|
||||
id="custom-task-name"
|
||||
aria-labelledby="custom-task-name-label"
|
||||
aria-describedby="custom-task-name-help"
|
||||
maxLength={120}
|
||||
@@ -228,8 +237,8 @@ export function CustomTaskDialog({
|
||||
placeholder={generatedTitle}
|
||||
value={title}
|
||||
/>
|
||||
<small id="custom-task-name-help">{t('customTask.nameHelp')}</small>
|
||||
</label>
|
||||
<span className="sr-only" id="custom-task-name-help">{t('customTask.nameHelp')}</span>
|
||||
</div>
|
||||
|
||||
<label className="custom-task-dialog__field">
|
||||
<span id="custom-task-destination-label">{t('customTask.fields.destination')}</span>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { modelOperationPercent } from './model-download-presentation'
|
||||
import { defaultHttpOcrSettings } from '../../shared/document-parsing-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { HttpOcrSettings } from './HttpOcrSettings'
|
||||
import { DocumentResultPreview } from './DocumentResultPreview'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
@@ -621,10 +622,12 @@ export function DocumentParsingSettingsSection({
|
||||
<div className="settings-section__title">
|
||||
<FileText aria-hidden="true" size={17} />
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong id="document-parsing-workflows-title">
|
||||
{t('documentParsing.workflows.title')}
|
||||
</strong>
|
||||
<small>{t('documentParsing.workflows.description')}</small>
|
||||
<InlineHelp label={t('documentParsing.workflows.title')}>{t('documentParsing.workflows.description')}</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-parsing-grid">
|
||||
@@ -721,10 +724,12 @@ export function DocumentParsingSettingsSection({
|
||||
<div className="settings-section__title settings-section__title--actions">
|
||||
<ScanText aria-hidden="true" size={17} />
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong id="document-parsing-ocr-title">
|
||||
{t('documentParsing.ocr.title')}
|
||||
</strong>
|
||||
<small>{t('documentParsing.ocr.description')}</small>
|
||||
<InlineHelp label={t('documentParsing.ocr.title')}>{t('documentParsing.ocr.description')}</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, Database, Eye, EyeOff, FolderOpen, RefreshCw, X } from 'lucide-react'
|
||||
import type { KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
|
||||
@@ -347,7 +348,7 @@ export function ExternalInstanceManager({ instances, libraries, onClose, onChang
|
||||
<Field label={t('external.address')}><input type="url" required value={draft.baseUrl} aria-describedby={/^http:\/\//i.test(draft.baseUrl) ? `${keyId}-transport` : undefined} onChange={event => change({ baseUrl: event.currentTarget.value })} /></Field>
|
||||
{/^http:\/\//i.test(draft.baseUrl) && <p className="external-knowledge__help" id={`${keyId}-transport`}>{t('external.transportNote')}</p>}
|
||||
<fieldset className="external-knowledge__group">
|
||||
<legend>{t('external.authentication')}</legend>
|
||||
<legend><span className="inline-help-label">{t('external.authentication')}<InlineHelp label={t('external.authentication')}>{t('external.credentialNote')}</InlineHelp></span></legend>
|
||||
{draft.id && <Field label={t('external.credentialAction')}><select value={draft.credential.action} onChange={event => { setShowKey(false); change({ credential: event.currentTarget.value === 'replace' ? { action: 'replace', value: '' } : { action: event.currentTarget.value as 'keep' | 'clear' } }) }}>
|
||||
{draft.id && <option value="keep">{t('external.keep')}</option>}<option value="replace">{t('external.replace')}</option>{draft.id && <option value="clear">{t('external.clear')}</option>}
|
||||
</select></Field>}
|
||||
@@ -357,7 +358,7 @@ export function ExternalInstanceManager({ instances, libraries, onClose, onChang
|
||||
<button type="button" className="icon-button" aria-label={t(showKey ? 'external.hideKey' : 'external.showKey')} title={t(showKey ? 'external.hideKey' : 'external.showKey')} aria-pressed={showKey} onClick={() => setShowKey(value => !value)}>{showKey ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>
|
||||
</div>}
|
||||
<p className="external-knowledge__help" id={`${keyId}-note`}>{t('external.credentialNote')}</p>
|
||||
<span className="sr-only" id={`${keyId}-note`}>{t('external.credentialNote')}</span>
|
||||
{draft.credential.action === 'clear' && <label><input type="checkbox" checked={clearConfirmed} onChange={event => setClearConfirmed(event.currentTarget.checked)} />{t('external.clearConfirm')}</label>}
|
||||
</fieldset>
|
||||
<div className="external-knowledge__fields">
|
||||
|
||||
@@ -153,6 +153,7 @@ describe('HeartbeatCenter', () => {
|
||||
it('renders English interface copy while preserving heartbeat content', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Smart Heartbeat' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
@@ -327,6 +328,7 @@ describe('HeartbeatCenter', () => {
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '智能心跳' })
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '智能心跳' })[1]!)
|
||||
expect(
|
||||
screen.getByText('心跳计划按时只读运行,不调用工具。')
|
||||
).toBeInTheDocument()
|
||||
@@ -425,6 +427,18 @@ describe('HeartbeatCenter', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps scope choices visible while showing help for the selected scope', () => {
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
|
||||
const help = screen.getByRole('button', { name: '回顾范围' })
|
||||
expect(help.closest('label, [role="tab"]')).toBeNull()
|
||||
fireEvent.click(help)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('回顾所有可用项目中的有界对话与任务')
|
||||
fireEvent.click(screen.getByRole('button', { name: '指定项目' }))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('一次运行共同回顾所选项目')
|
||||
expect(screen.getByRole('checkbox', { name: '默认项目' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('creates one heartbeat plan for multiple selected projects', async () => {
|
||||
const onCreate = vi.fn(async () => {})
|
||||
const secondProject = {
|
||||
|
||||
@@ -414,7 +414,7 @@ export function HeartbeatCenter({
|
||||
</>
|
||||
)
|
||||
}
|
||||
description={t('center.description')}
|
||||
help={t('center.description')}
|
||||
headingId="heartbeat-center-title"
|
||||
icon={<HeartPulse size={22} />}
|
||||
scope={heartbeatScope}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HeartPulse, Pencil } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import {
|
||||
heartbeatCreateSchema,
|
||||
type AssistantHeartbeatConfig,
|
||||
@@ -179,11 +180,13 @@ export function HeartbeatSettings({
|
||||
return (
|
||||
<div className="heartbeat-settings">
|
||||
<div className="heartbeat-settings__intro">
|
||||
<h2>
|
||||
<HeartPulse size={15} />
|
||||
{t('settings.title')}
|
||||
</h2>
|
||||
<p>{t('settings.description')}</p>
|
||||
<div className="inline-help-label">
|
||||
<h2>
|
||||
<HeartPulse size={15} />
|
||||
{t('settings.title')}
|
||||
</h2>
|
||||
<InlineHelp label={t('settings.title')}>{t('settings.description')}</InlineHelp>
|
||||
</div>
|
||||
</div>
|
||||
<div className="heartbeat-settings__editor">
|
||||
<div className="heartbeat-settings__editor-heading">
|
||||
@@ -212,7 +215,16 @@ export function HeartbeatSettings({
|
||||
/>
|
||||
</label>
|
||||
<fieldset className="heartbeat-settings__scope">
|
||||
<legend>{t('settings.scope.legend')}</legend>
|
||||
<legend>
|
||||
<span className="inline-help-label">
|
||||
{t('settings.scope.legend')}
|
||||
<InlineHelp label={t('settings.scope.legend')}>
|
||||
{scopeKind === 'global'
|
||||
? t('settings.scope.globalHelp')
|
||||
: t('settings.scope.projectsHelp')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</legend>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('settings.scope.ariaLabel')}
|
||||
disabled={pendingAction !== undefined}
|
||||
@@ -229,11 +241,6 @@ export function HeartbeatSettings({
|
||||
]}
|
||||
value={scopeKind}
|
||||
/>
|
||||
<p>
|
||||
{scopeKind === 'global'
|
||||
? t('settings.scope.globalHelp')
|
||||
: t('settings.scope.projectsHelp')}
|
||||
</p>
|
||||
{scopeKind === 'projects' && (
|
||||
<div className="heartbeat-settings__project-list">
|
||||
{selectableProjects.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { PageHeader } from './WorkspacePrimitives'
|
||||
import { SettingsSectionHeader } from './SettingsPrimitives'
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers() })
|
||||
|
||||
it('preserves externally referenced descriptions while closed and never duplicates their IDs', () => {
|
||||
render(<>
|
||||
<input aria-label="Context size" aria-describedby="context-description" defaultValue="128" />
|
||||
<InlineHelp label="Context help" id="context-description">Keep this model context size.</InlineHelp>
|
||||
</>)
|
||||
const input = screen.getByRole('textbox')
|
||||
const trigger = screen.getByRole('button', { name: 'Context help' })
|
||||
const check = (): void => {
|
||||
expect(input).toHaveAccessibleDescription('Keep this model context size.')
|
||||
expect(trigger).toHaveAccessibleDescription('Keep this model context size.')
|
||||
expect(document.querySelectorAll('#context-description')).toHaveLength(1)
|
||||
expect(input).toHaveValue('128')
|
||||
}
|
||||
check()
|
||||
expect(document.getElementById('context-description')).not.toBeVisible()
|
||||
fireEvent.click(trigger)
|
||||
check()
|
||||
expect(screen.getByRole('tooltip')).toBeVisible()
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
check()
|
||||
expect(document.getElementById('context-description')).not.toBeVisible()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports hover transfer, focus, pinned clicks, outside dismissal and isolated Escape', () => {
|
||||
vi.useFakeTimers()
|
||||
const parentEscape = vi.fn()
|
||||
render(<section role="dialog" aria-modal="true" onKeyDown={parentEscape}>
|
||||
<button>Outside</button>
|
||||
<InlineHelp label="Context" id="context-help">Long help text</InlineHelp>
|
||||
</section>)
|
||||
const trigger = screen.getByRole('button', { name: 'Context' })
|
||||
const outside = screen.getByText('Outside')
|
||||
outside.focus()
|
||||
fireEvent.mouseEnter(trigger)
|
||||
expect(outside).toHaveFocus()
|
||||
expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('tooltip'))
|
||||
expect(trigger).toHaveAttribute('aria-describedby', 'context-help')
|
||||
fireEvent.mouseLeave(trigger)
|
||||
fireEvent.mouseEnter(screen.getByRole('tooltip'))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
fireEvent.mouseLeave(screen.getByRole('tooltip'))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
act(() => trigger.focus())
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.mouseLeave(trigger)
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
expect(parentEscape).not.toHaveBeenCalled()
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(outside)
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
act(() => outside.focus())
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds optional help beside both header titles without replacing descriptions', () => {
|
||||
render(<>
|
||||
<PageHeader headingId="page" title="Page" help="Page help" description="Page description" />
|
||||
<SettingsSectionHeader headingId="section" title="Section" help="Section help" description="Section description" />
|
||||
</>)
|
||||
for (const title of ['Page', 'Section']) {
|
||||
const trigger = screen.getByRole('button', { name: title })
|
||||
expect(trigger.parentElement).toHaveClass('inline-help-label')
|
||||
expect(trigger.closest('h1, h2')).toHaveTextContent(title)
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(`${title} help`)
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(screen.getByText(`${title} description`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { FloatingPortal } from './FloatingPortal'
|
||||
import './inline-help.css'
|
||||
|
||||
export function InlineHelp({ label, children, id }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
id?: string
|
||||
}): React.JSX.Element {
|
||||
const generatedId = useId()
|
||||
const contentId = id ?? generatedId
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const pinned = useRef(false)
|
||||
const hovered = useRef(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const [open, setOpen] = useState(false)
|
||||
const cancelClose = (): void => { clearTimeout(timer.current) }
|
||||
const close = (): void => {
|
||||
cancelClose()
|
||||
pinned.current = false
|
||||
setOpen(false)
|
||||
}
|
||||
const leave = (): void => {
|
||||
hovered.current = false
|
||||
cancelClose()
|
||||
timer.current = setTimeout(() => {
|
||||
if (!pinned.current && !hovered.current && document.activeElement !== anchorRef.current &&
|
||||
!panelRef.current?.contains(document.activeElement)) setOpen(false)
|
||||
}, 120)
|
||||
}
|
||||
const enter = (): void => {
|
||||
hovered.current = true
|
||||
cancelClose()
|
||||
setOpen(true)
|
||||
}
|
||||
const dismiss = useEffectEvent(close)
|
||||
const position = useEffectEvent(() => {
|
||||
const panel = panelRef.current
|
||||
const anchor = anchorRef.current
|
||||
if (!panel || !anchor) return
|
||||
const viewport = window.visualViewport
|
||||
const left = viewport?.offsetLeft ?? 0
|
||||
const top = viewport?.offsetTop ?? 0
|
||||
const width = viewport?.width ?? window.innerWidth
|
||||
const height = viewport?.height ?? window.innerHeight
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const panelWidth = Math.max(0, Math.min(360, width - 32))
|
||||
panel.style.width = `${panelWidth}px`
|
||||
panel.style.maxHeight = `${Math.max(0, height - 32)}px`
|
||||
const panelHeight = panel.getBoundingClientRect().height
|
||||
const below = rect.bottom + 8
|
||||
const y = below + panelHeight <= top + height - 16 ? below : rect.top - 8 - panelHeight
|
||||
panel.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - panelWidth - 16))}px`
|
||||
panel.style.top = `${Math.max(top + 16, Math.min(y, top + height - panelHeight - 16))}px`
|
||||
})
|
||||
useLayoutEffect(() => { if (open) position() })
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const inside = (target: EventTarget | null): boolean => target instanceof Node &&
|
||||
!!(anchorRef.current?.contains(target) || panelRef.current?.contains(target))
|
||||
const outside = (event: Event): void => { if (!inside(event.target)) dismiss() }
|
||||
const keydown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dismiss()
|
||||
}
|
||||
const reposition = (): void => position()
|
||||
const observer = new ResizeObserver(reposition)
|
||||
if (panelRef.current) observer.observe(panelRef.current)
|
||||
if (anchorRef.current) observer.observe(anchorRef.current)
|
||||
document.addEventListener('pointerdown', outside)
|
||||
document.addEventListener('focusin', outside)
|
||||
// Run before modal/document handlers, including when hover did not move focus.
|
||||
window.addEventListener('keydown', keydown, true)
|
||||
window.addEventListener('resize', reposition)
|
||||
window.addEventListener('scroll', reposition, true)
|
||||
window.visualViewport?.addEventListener('resize', reposition)
|
||||
window.visualViewport?.addEventListener('scroll', reposition)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
document.removeEventListener('pointerdown', outside)
|
||||
document.removeEventListener('focusin', outside)
|
||||
window.removeEventListener('keydown', keydown, true)
|
||||
window.removeEventListener('resize', reposition)
|
||||
window.removeEventListener('scroll', reposition, true)
|
||||
window.visualViewport?.removeEventListener('resize', reposition)
|
||||
window.visualViewport?.removeEventListener('scroll', reposition)
|
||||
}
|
||||
}, [open])
|
||||
return <>
|
||||
<button type="button" className="inline-help" ref={anchorRef} aria-label={label}
|
||||
aria-expanded={open} aria-controls={open ? contentId : undefined} aria-describedby={open || id ? contentId : undefined}
|
||||
onMouseEnter={enter} onMouseLeave={leave} onFocus={() => { cancelClose(); setOpen(true) }} onBlur={leave}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (pinned.current) close()
|
||||
else { cancelClose(); pinned.current = true; setOpen(true) }
|
||||
}}>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{/* Referenced hidden text remains an accessible description without a second visible copy. */}
|
||||
{!open && id && <span id={contentId} hidden>{children}</span>}
|
||||
{open && <FloatingPortal anchorRef={anchorRef}>
|
||||
<div className="inline-help__content" id={contentId} role="tooltip" ref={panelRef}
|
||||
onMouseEnter={enter} onMouseLeave={leave} onFocus={cancelClose} onBlur={leave}>
|
||||
{children}
|
||||
</div>
|
||||
</FloatingPortal>}
|
||||
</>
|
||||
}
|
||||
@@ -106,6 +106,27 @@ describe('KnowledgeRetrievalWorkbench', () => {
|
||||
).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('consolidates static help while keeping unavailable channels and validation visible', () => {
|
||||
render(<KnowledgeRetrievalWorkbench {...createProps({ graphAvailable: false })} />)
|
||||
openAdvancedSettings()
|
||||
const help = screen.getByRole('button', { name: '通道融合占比(合计 100%)' })
|
||||
expect(help.closest('label, summary')).toBeNull()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
fireEvent.click(help)
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('按相对占比参与融合;可用通道建议合计 100%')
|
||||
expect(screen.getAllByText('按相对占比参与融合;可用通道建议合计 100%')).toHaveLength(1)
|
||||
expect(screen.getByText('当前知识库未启用图谱,此权重暂不生效。')).toBeVisible()
|
||||
expect(screen.getByRole('tooltip')).not.toHaveTextContent('未启用')
|
||||
fireEvent.keyDown(help, { key: 'Escape' })
|
||||
const adjacent = screen.getByRole('spinbutton', { name: '相邻分块数' })
|
||||
fireEvent.change(adjacent, { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
|
||||
expect(screen.getByText('相邻分块数必须是 0 至 2 的整数。')).toBeVisible()
|
||||
expect(adjacent).toHaveAccessibleDescription('相邻分块数必须是 0 至 2 的整数。')
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('provides dialog semantics, focuses the persistent query, and returns focus on close', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../shared/knowledge-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
|
||||
export type KnowledgeRetrievalChannel =
|
||||
SharedKnowledgeRetrievalChannel
|
||||
@@ -458,9 +459,9 @@ export function KnowledgeRetrievalWorkbench({
|
||||
className="knowledge-workbench-section"
|
||||
>
|
||||
<div className="knowledge-workbench-section__heading">
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<h3 id={`${titleId}-query`}>{t('retrieval.query.title')}</h3>
|
||||
<p>{t('retrieval.query.help')}</p>
|
||||
<InlineHelp label={t('retrieval.query.title')}>{t('retrieval.query.help')}</InlineHelp>
|
||||
</div>
|
||||
<span className="knowledge-character-count">
|
||||
{t('retrieval.query.count', { count: query.length })}
|
||||
@@ -587,11 +588,11 @@ export function KnowledgeRetrievalWorkbench({
|
||||
>
|
||||
<div className="knowledge-workbench-settings__group-heading">
|
||||
<span>1</span>
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<h4 id={`${titleId}-recall-settings`}>
|
||||
{t('retrieval.settings.groups.recall.title')}
|
||||
</h4>
|
||||
<p>{t('retrieval.settings.groups.recall.description')}</p>
|
||||
<InlineHelp label={t('retrieval.settings.groups.recall.title')}>{t('retrieval.settings.groups.recall.description')}</InlineHelp>
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-workbench-settings__grid knowledge-workbench-settings__grid--three">
|
||||
@@ -655,9 +656,10 @@ export function KnowledgeRetrievalWorkbench({
|
||||
</label>
|
||||
</div>
|
||||
<div className="knowledge-workbench-settings__weights">
|
||||
<span className="knowledge-workbench-settings__subheading">
|
||||
{t('retrieval.settings.channelWeights')}
|
||||
</span>
|
||||
<div className="inline-help-label">
|
||||
<span className="knowledge-workbench-settings__subheading">{t('retrieval.settings.channelWeights')}</span>
|
||||
<InlineHelp label={t('retrieval.settings.channelWeights')}>{t('retrieval.settings.weightHelp')}</InlineHelp>
|
||||
</div>
|
||||
<div className="knowledge-workbench-settings__grid knowledge-workbench-settings__grid--three">
|
||||
{(
|
||||
weightKeys
|
||||
@@ -682,13 +684,11 @@ export function KnowledgeRetrievalWorkbench({
|
||||
/>
|
||||
<span aria-hidden="true">%</span>
|
||||
</span>
|
||||
<small>
|
||||
{(showSettingsValidation && validationErrors[key] || key === 'graphWeight' && !graphAvailable) && <small>
|
||||
{showSettingsValidation && validationErrors[key]
|
||||
? validationErrors[key]
|
||||
: key === 'graphWeight' && !graphAvailable
|
||||
? t('retrieval.settings.graphUnavailable')
|
||||
: t('retrieval.settings.weightHelp')}
|
||||
</small>
|
||||
: t('retrieval.settings.graphUnavailable')}
|
||||
</small>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -701,11 +701,11 @@ export function KnowledgeRetrievalWorkbench({
|
||||
>
|
||||
<div className="knowledge-workbench-settings__group-heading">
|
||||
<span>2–4</span>
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<h4 id={`${titleId}-output-settings`}>
|
||||
{t('retrieval.settings.groups.output.title')}
|
||||
</h4>
|
||||
<p>{t('retrieval.settings.groups.output.description')}</p>
|
||||
<InlineHelp label={t('retrieval.settings.groups.output.title')}>{t('retrieval.settings.groups.output.description')}</InlineHelp>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
@@ -794,9 +794,14 @@ export function KnowledgeRetrievalWorkbench({
|
||||
: t('retrieval.settings.contextBudgetHelp')}
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('retrieval.settings.adjacentCount')}</span>
|
||||
<div className="field">
|
||||
<div className="inline-help-label">
|
||||
<label htmlFor={`${titleId}-adjacent-count`}>{t('retrieval.settings.adjacentCount')}</label>
|
||||
<InlineHelp label={t('retrieval.settings.adjacentCount')}>{t('retrieval.settings.adjacentCountHelp')}</InlineHelp>
|
||||
</div>
|
||||
<input
|
||||
id={`${titleId}-adjacent-count`}
|
||||
aria-describedby={showSettingsValidation && validationErrors.adjacentChunkCount ? `${titleId}-adjacent-error` : undefined}
|
||||
aria-invalid={
|
||||
showSettingsValidation &&
|
||||
Boolean(validationErrors.adjacentChunkCount)
|
||||
@@ -817,13 +822,8 @@ export function KnowledgeRetrievalWorkbench({
|
||||
: draftSettings.adjacentChunkCount
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
{showSettingsValidation &&
|
||||
validationErrors.adjacentChunkCount
|
||||
? validationErrors.adjacentChunkCount
|
||||
: t('retrieval.settings.adjacentCountHelp')}
|
||||
</small>
|
||||
</label>
|
||||
{showSettingsValidation && validationErrors.adjacentChunkCount && <small id={`${titleId}-adjacent-error`}>{validationErrors.adjacentChunkCount}</small>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -298,6 +298,13 @@ describe('KnowledgeWorkspace', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }))
|
||||
const advancedHelp = screen.getByRole('button', { name: '高级设置' })
|
||||
const advancedDetails = screen.getByText('高级设置').closest('details')!
|
||||
expect(advancedHelp.closest('summary, label, button button')).toBeNull()
|
||||
fireEvent.click(advancedHelp)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('仅在需要托管副本或知识图谱时调整。')
|
||||
expect(advancedDetails).not.toHaveAttribute('open')
|
||||
fireEvent.keyDown(advancedHelp, { key: 'Escape' })
|
||||
fireEvent.change(screen.getByLabelText('名称'), {
|
||||
target: { value: '客户研究' }
|
||||
})
|
||||
@@ -305,6 +312,9 @@ describe('KnowledgeWorkspace', () => {
|
||||
target: { value: '访谈与反馈' }
|
||||
})
|
||||
fireEvent.click(screen.getByText('高级设置'))
|
||||
fireEvent.click(advancedHelp)
|
||||
expect(advancedDetails).toHaveAttribute('open')
|
||||
fireEvent.keyDown(advancedHelp, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByLabelText(/引用原文件/))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /启用知识图谱/u })
|
||||
@@ -1502,10 +1512,10 @@ describe('KnowledgeWorkspace', () => {
|
||||
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
|
||||
target: { value: 'entity-1' }
|
||||
})
|
||||
expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass(
|
||||
expect(screen.getByRole('region', { name: '知识图谱画布' }).parentElement).toHaveClass(
|
||||
'knowledge-graph'
|
||||
)
|
||||
expect(screen.getByLabelText('知识图谱画布').parentElement)
|
||||
expect(screen.getByRole('region', { name: '知识图谱画布' }).parentElement)
|
||||
.not.toHaveClass('knowledge-graph--with-details')
|
||||
expect(screen.getByLabelText('实体详情')).toHaveClass(
|
||||
'knowledge-graph__detail'
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type {
|
||||
KnowledgeDocumentItem as SharedKnowledgeDocumentItem,
|
||||
@@ -663,6 +664,8 @@ function CreateLibraryWizard({
|
||||
const [description, setDescription] = useState('')
|
||||
const [storageMode, setStorageMode] =
|
||||
useState<KnowledgeStorageMode>('reference')
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const advancedId = useId()
|
||||
const [graphEnabled, setGraphEnabled] = useState(false)
|
||||
const [graphStrategy, setGraphStrategy] =
|
||||
useState<KnowledgeGraphStrategy>('rules')
|
||||
@@ -730,14 +733,21 @@ function CreateLibraryWizard({
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
<details className="knowledge-create__advanced">
|
||||
<summary>
|
||||
<div className="inline-help-label">
|
||||
<details className="knowledge-create__advanced" open={advancedOpen}>
|
||||
<summary aria-controls={advancedOpen ? advancedId : undefined} onClick={(event) => {
|
||||
event.preventDefault()
|
||||
setAdvancedOpen(!advancedOpen)
|
||||
}}>
|
||||
<span>
|
||||
<strong>{t('create.advanced')}</strong>
|
||||
<small>{t('create.advancedDescription')}</small>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="knowledge-create__advanced-content">
|
||||
</details>
|
||||
<InlineHelp label={t('create.advanced')}>{t('create.advancedDescription')}</InlineHelp>
|
||||
</div>
|
||||
{advancedOpen && (
|
||||
<div className="knowledge-create__advanced-content" id={advancedId}>
|
||||
<fieldset className="knowledge-create__storage">
|
||||
<legend>
|
||||
{t('fields.storageMode')}
|
||||
@@ -809,7 +819,7 @@ function CreateLibraryWizard({
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
@@ -2470,11 +2480,11 @@ function KnowledgeSettingsView({
|
||||
>
|
||||
{mode === 'index' && (
|
||||
<section className="knowledge-settings__section">
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<h3>{t('settings.retrieval.title')}</h3>
|
||||
<p className="knowledge-section-description">
|
||||
<InlineHelp label={t('settings.retrieval.title')}>
|
||||
{t('settings.retrieval.description')}
|
||||
</p>
|
||||
</InlineHelp>
|
||||
</div>
|
||||
<div className="knowledge-settings__actions">
|
||||
<button
|
||||
@@ -2734,13 +2744,13 @@ function KnowledgeSettingsView({
|
||||
aria-labelledby="knowledge-ontology-settings-title"
|
||||
className="knowledge-settings__ontology knowledge-settings__section"
|
||||
>
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<h3 id="knowledge-ontology-settings-title">
|
||||
{t('settings.ontology.title')}
|
||||
</h3>
|
||||
<p className="knowledge-section-description">
|
||||
<InlineHelp label={t('settings.ontology.title')}>
|
||||
{t('settings.ontology.description')}
|
||||
</p>
|
||||
</InlineHelp>
|
||||
</div>
|
||||
{library.ontologyRebuildRequired && (
|
||||
<p className="knowledge-settings__rebuild-note" role="status">
|
||||
@@ -3889,6 +3899,7 @@ function GraphView({
|
||||
<RotateCcw aria-hidden="true" size={15} />
|
||||
{t('graph.fitView')}
|
||||
</button>
|
||||
<InlineHelp label={t('graph.canvasAriaLabel')}>{t('graph.interactionHint')}</InlineHelp>
|
||||
{reextractError && (
|
||||
<span
|
||||
className="knowledge-graph__toolbar-error"
|
||||
@@ -3946,9 +3957,6 @@ function GraphView({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="knowledge-graph__interaction-hint">
|
||||
{t('graph.interactionHint')}
|
||||
</p>
|
||||
{visibleRelations.length === 0 ? (
|
||||
<p className="knowledge-graph__panel-empty">
|
||||
{t('graph.visibleRelations.empty')}
|
||||
|
||||
@@ -49,7 +49,10 @@ describe('LocalInferencePage', () => {
|
||||
it('keeps the service toolbar responsive', async () => {
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
const toolbar = screen.getByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心').parentElement!
|
||||
expect(screen.queryByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控' }))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('每 5 秒刷新 · CPU 100% = 一个逻辑核心')
|
||||
const toolbar = screen.getByRole('button', { name: '刷新状态' }).closest<HTMLElement>('.local-inference-toolbar')!
|
||||
expect(toolbar).toHaveClass('local-inference-toolbar')
|
||||
expect(within(toolbar).getByRole('button', { name: '刷新状态' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
@@ -138,10 +141,12 @@ describe('LocalInferencePage', () => {
|
||||
render(<LocalInferencePage onClose={onClose} />)
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus()
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理监控' }), { key: 'Tab', shiftKey: true })
|
||||
const help = screen.getByRole('button', { name: '本机推理监控' })
|
||||
help.focus()
|
||||
fireEvent.keyDown(help, { key: 'Tab', shiftKey: true })
|
||||
expect(screen.getByRole('button', { name: '重启服务' })).toHaveFocus()
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: '重启服务' }), { key: 'Tab' })
|
||||
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus()
|
||||
expect(help).toHaveFocus()
|
||||
const stop = screen.getByRole('button', { name: '停止服务' })
|
||||
stop.focus()
|
||||
fireEvent.click(stop)
|
||||
|
||||
@@ -122,13 +122,13 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc
|
||||
}}>
|
||||
<div className="custom-task-dialog__header">
|
||||
<PageHeader headingId="local-inference-title" title="本机推理监控" scope={{ kind: 'global' }}
|
||||
help={enabled ? '每 5 秒刷新 · CPU 100% = 一个逻辑核心' : undefined}
|
||||
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理监控" title="关闭本机推理监控" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
|
||||
</div>
|
||||
<div className="local-inference-modal__body">
|
||||
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong></div>}
|
||||
<div className="local-inference-workspace" hidden={!enabled} inert={!enabled}>
|
||||
<div className="local-inference-toolbar">
|
||||
<p className="local-inference-note">每 5 秒刷新 · CPU 100% = 一个逻辑核心</p>
|
||||
<div className="local-inference-actions"><button className="secondary-button" type="button" disabled={busy} onClick={() => void window.goodbuddy.localInference.openSettings().catch((reason: unknown) => setOperationError(String(reason)))}>模型与连接设置</button><button className="secondary-button" type="button" disabled={refreshing || busy} onClick={() => setReload((value) => value + 1)}>刷新状态</button></div>
|
||||
</div>
|
||||
{error && <div className="local-inference-feedback" role="alert"><p>{snapshot ? '状态刷新失败;以下为上次成功读取的状态,操作已禁用。' : '无法读取本机推理状态。'} {error}</p><button type="button" className="secondary-button" onClick={() => setReload((value) => value + 1)}>重试</button></div>}
|
||||
|
||||
@@ -161,6 +161,23 @@ async function openNote(title = detail.title): Promise<void> {
|
||||
await screen.findByDisplayValue(title)
|
||||
}
|
||||
|
||||
it.each(['immediate', 'after-save-auto', 'after-save-manual'] as const)('shows complete text composer help for %s without triggering analysis', async (mode) => {
|
||||
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: mode })
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const help = screen.getByRole('button', { name: '新记录内容' })
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
expect(help.closest('label, button button, [role="tab"]')).toBeNull()
|
||||
fireEvent.click(help)
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent('输入 -、1.、[ ] 或 [x] 后按空格可创建列表')
|
||||
expect(tooltip).toHaveTextContent(mode === 'immediate' ? '停止输入 5 秒后自动评论' : '也可粘贴或拖入本地文件')
|
||||
if (mode === 'after-save-auto') expect(tooltip).toHaveTextContent('保存记录后,AI 会自动评论。')
|
||||
expect(screen.getByRole('button', { name: '保存记录' })).toBeVisible()
|
||||
expect(analyzeDraft).not.toHaveBeenCalled()
|
||||
expect(analyze).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function back(): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回总览' }))
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type {
|
||||
MagicNoteAnalysisOptions,
|
||||
@@ -1991,7 +1992,7 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={detailView ? undefined : t('page.description')}
|
||||
help={detailView ? undefined : t('page.description')}
|
||||
headingId="magic-notes-title"
|
||||
icon={<Sparkles size={20} />}
|
||||
scope={{ kind: 'global' }}
|
||||
@@ -2397,21 +2398,24 @@ export function MagicNotesWorkspace({
|
||||
<section aria-label={t('comments.paneLabel')} className="magic-todo-comments">
|
||||
<strong>{t('comments.paneLabel')}</strong>
|
||||
<div className="magic-notes-ai-controls">
|
||||
<label>
|
||||
<span>{t('comments.directionLabel')}</span>
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<label htmlFor="magic-todo-comment-direction">{t('comments.directionLabel')}</label>
|
||||
<InlineHelp label={t('comments.directionLabel')}>{t('comments.directionHelp')}</InlineHelp>
|
||||
</div>
|
||||
<select
|
||||
id="magic-todo-comment-direction"
|
||||
aria-label={t('comments.directionAriaLabel')}
|
||||
onChange={(event) => setCommentDirection(event.target.value as MagicNoteCommentDirection)}
|
||||
value={commentDirection}
|
||||
>
|
||||
{commentDirections.map((direction) => <option key={direction.value} value={direction.value}>{direction.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeTodo(selectedTodo.id)} type="button">
|
||||
<Bot aria-hidden="true" size={14} />
|
||||
{t(busy === `analyze-todo-${selectedTodo.id}` ? 'actions.analyzing' : selectedTodo.analyzedAt ? 'actions.analyzeAgain' : 'actions.analyze')}
|
||||
</button>
|
||||
<small>{t('comments.directionHelp')}</small>
|
||||
</div>
|
||||
{liveAnalysis && busy === `analyze-todo-${selectedTodo.id}` && (
|
||||
<div className="magic-notes-ai-live" aria-live="polite">
|
||||
@@ -2549,6 +2553,12 @@ export function MagicNotesWorkspace({
|
||||
<div className="magic-note-composer__header">
|
||||
<div className="magic-note-entry-type" role="group" aria-label={t('canvas.entryType')}>
|
||||
{(['text', 'canvas'] as const).map((value) => <button key={value} type="button" className="secondary-button" aria-pressed={entryType === value} disabled={Boolean(busy) || entryType === value} onClick={() => void requestDraftSwitch({ kind: 'entry-type', value })}>{t(`canvas.${value}`)}</button>)}
|
||||
{entryType === 'text' && <InlineHelp label={t('notes.newEntryLabel')}>
|
||||
<p>{commentMode === 'immediate'
|
||||
? t('notes.composerImmediateHint')
|
||||
: t('notes.composerRichTextHint')}</p>
|
||||
{commentMode === 'after-save-auto' && <p>{t('comments.autoHint')}</p>}
|
||||
</InlineHelp>}
|
||||
</div>
|
||||
{entryType === 'canvas' && <div className="magic-note-canvas-actions">
|
||||
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(false)}>{t('canvas.analyzeDraft')}</button>
|
||||
@@ -2606,12 +2616,7 @@ export function MagicNotesWorkspace({
|
||||
{validation.message}
|
||||
</p>
|
||||
)}
|
||||
{entryType === 'text' && <footer>
|
||||
<span>
|
||||
{commentMode === 'immediate'
|
||||
? t('notes.composerImmediateHint')
|
||||
: t('notes.composerRichTextHint')}
|
||||
</span>
|
||||
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={Boolean(busy)}
|
||||
@@ -2882,9 +2887,13 @@ export function MagicNotesWorkspace({
|
||||
id="magic-notes-ai-pane"
|
||||
>
|
||||
<div className="magic-notes-ai-controls">
|
||||
<label>
|
||||
<span>{t('comments.directionLabel')}</span>
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<label htmlFor="magic-note-comment-direction">{t('comments.directionLabel')}</label>
|
||||
<InlineHelp label={t('comments.directionLabel')}>{t('comments.directionHelp')}</InlineHelp>
|
||||
</div>
|
||||
<select
|
||||
id="magic-note-comment-direction"
|
||||
aria-label={t('comments.directionAriaLabel')}
|
||||
onChange={(event) =>
|
||||
setCommentDirection(
|
||||
@@ -2899,10 +2908,7 @@ export function MagicNotesWorkspace({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<small>
|
||||
{t('comments.directionHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{liveAnalysis?.format === 'structured' ? (
|
||||
<p className="magic-notes-muted" role="status">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { McpSettingsSection } from './McpSettingsSection'
|
||||
import i18n from './i18n'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('moves the MCP introduction to title help and retains the custom server notice', async () => {
|
||||
const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] })
|
||||
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
await waitFor(() => expect(getSnapshot).toHaveBeenCalledOnce())
|
||||
expect(screen.getByRole('heading', { name: i18n.t('integrations:mcp.title') }).closest('header')?.querySelector('p')).toBeNull()
|
||||
expect(screen.queryByText(i18n.t('integrations:mcp.description'))).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.title') }))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(i18n.t('integrations:mcp.description'))
|
||||
expect(screen.getByRole('tooltip').querySelector('button')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('tab', { name: i18n.t('integrations:mcp.tabs.custom') }))
|
||||
expect(screen.getByText(i18n.t('integrations:mcp.customNotice'))).toBeVisible()
|
||||
})
|
||||
@@ -409,7 +409,7 @@ export function McpSettingsSection({
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
description={t('mcp.description')}
|
||||
help={t('mcp.description')}
|
||||
error={!editor ? error : undefined}
|
||||
headingLevel={3}
|
||||
headingId="mcp-settings-heading"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
ModelDownloadSource
|
||||
@@ -553,12 +554,12 @@ export function PlatformFeaturesSettingsSection({
|
||||
>
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>{t('platformFeatures.shortcut.title')}</strong>
|
||||
<small>
|
||||
<InlineHelp label={t('platformFeatures.shortcut.title')}>
|
||||
{t('platformFeatures.shortcut.description')}
|
||||
</small>
|
||||
</div>
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
{shortcutDraft && shortcutSnapshot ? (
|
||||
<>
|
||||
@@ -660,14 +661,14 @@ export function PlatformFeaturesSettingsSection({
|
||||
{settings && (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>
|
||||
{t('platformFeatures.conversationHtml.title')}
|
||||
</strong>
|
||||
<small>
|
||||
<InlineHelp label={t('platformFeatures.conversationHtml.title')}>
|
||||
{t('platformFeatures.conversationHtml.description')}
|
||||
</small>
|
||||
</div>
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
@@ -690,16 +691,16 @@ export function PlatformFeaturesSettingsSection({
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>
|
||||
{t('platformFeatures.modelDownloadSource.title')}
|
||||
</strong>
|
||||
<small>
|
||||
<InlineHelp label={t('platformFeatures.modelDownloadSource.title')}>
|
||||
{t(
|
||||
'platformFeatures.modelDownloadSource.description'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<fieldset className="model-download-source">
|
||||
<legend className="sr-only">
|
||||
@@ -762,16 +763,16 @@ export function PlatformFeaturesSettingsSection({
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>
|
||||
{t('platformFeatures.remoteProjects.title')}
|
||||
</strong>
|
||||
<small>
|
||||
<InlineHelp label={t('platformFeatures.remoteProjects.title')}>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.description'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
|
||||
@@ -1430,7 +1430,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
within(dialog).getByRole('option', { name: /Build host/u })
|
||||
).toBeInTheDocument()
|
||||
)
|
||||
expect(within(dialog).getByLabelText('SSH 主机')).toBeDisabled()
|
||||
expect(within(dialog).getByRole('combobox', { name: 'SSH 主机' })).toBeDisabled()
|
||||
expect(within(dialog).getByLabelText('远端工作目录')).toBeDisabled()
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', {
|
||||
@@ -1512,7 +1512,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
/此主机尚未完成 Host Key 和连接验证/u
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('SSH 主机')).toHaveValue(hostId)
|
||||
expect(within(dialog).getByRole('combobox', { name: 'SSH 主机' })).toHaveValue(hostId)
|
||||
expect(
|
||||
within(dialog).getByRole('option', {
|
||||
name: /Build host.*需要验证/u
|
||||
|
||||
@@ -56,6 +56,7 @@ import type { ProjectActivityCountsProps } from './ProjectActivity'
|
||||
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { ChannelIcon } from './ChannelIcon'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
|
||||
type ProjectSwitcherProps = {
|
||||
@@ -1582,11 +1583,15 @@ export function ProjectSwitcher({
|
||||
</>
|
||||
) : (
|
||||
<div className="remote-project-fields">
|
||||
<label>
|
||||
<span>
|
||||
<div className="field">
|
||||
<div className="inline-help-label">
|
||||
<label htmlFor="project-remote-host">
|
||||
{t('projectSwitcher.remote.fields.host')}
|
||||
</span>
|
||||
</label>
|
||||
<InlineHelp label={t('projectSwitcher.remote.fields.host')}>{t('projectSwitcher.remote.hostHelp')}</InlineHelp>
|
||||
</div>
|
||||
<select
|
||||
id="project-remote-host"
|
||||
aria-label={t(
|
||||
'projectSwitcher.remote.fields.host'
|
||||
)}
|
||||
@@ -1638,9 +1643,6 @@ export function ProjectSwitcher({
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<small>
|
||||
{t('projectSwitcher.remote.hostHelp')}
|
||||
</small>
|
||||
{remoteHostId &&
|
||||
selectedRemoteHostReadiness && (
|
||||
<small
|
||||
@@ -1657,7 +1659,7 @@ export function ProjectSwitcher({
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>
|
||||
{t('projectSwitcher.remote.fields.root')}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('opens snapshot guidance without hiding empty state or closing the dialog on Escape', async () => {
|
||||
const onClose = vi.fn()
|
||||
vi.stubGlobal('goodbuddy', { conversationQueue: { getAttachments: vi.fn().mockResolvedValue([]) } })
|
||||
render(<QueuedAttachmentsDialog itemId="queued" onClose={onClose} />)
|
||||
expect(await screen.findByText('此输入没有附件。')).toBeVisible()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
const help = screen.getByRole('button', { name: '入队时的附件' })
|
||||
fireEvent.click(help)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('这里显示入队时的内容。需要修改附件时,请先将整条输入恢复到草稿。')
|
||||
fireEvent.keyDown(help, { key: 'Escape' })
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭附件预览' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { ContextAttachment } from '../../shared/contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { AttachmentResultButton } from './AttachmentResultButton'
|
||||
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
|
||||
export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element {
|
||||
const close = useRef<HTMLButtonElement>(null)
|
||||
@@ -22,8 +23,7 @@ export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; o
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose() }
|
||||
}}>
|
||||
<header><strong>入队时的附件</strong><button type="button" className="secondary-button" ref={close} onClick={onClose}>关闭附件预览</button></header>
|
||||
<p>这里显示入队时的内容。需要修改附件时,请先将整条输入恢复到草稿。</p>
|
||||
<header><span className="inline-help-label"><strong>入队时的附件</strong><InlineHelp label="入队时的附件">这里显示入队时的内容。需要修改附件时,请先将整条输入恢复到草稿。</InlineHelp></span><button type="button" className="secondary-button" ref={close} onClick={onClose}>关闭附件预览</button></header>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{!items && !error && <p role="status">正在读取附件</p>}
|
||||
{items?.length === 0 && <p>此输入没有附件。</p>}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Bot, Plus, Save, Trash2 } from 'lucide-react'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
AssistantExpert,
|
||||
ExpertCreateInput
|
||||
@@ -378,9 +379,13 @@ export function RolePromptSettingsSection({
|
||||
})}
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('roles.fields.modelConnection')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor="role-model-profile">{t('roles.fields.modelConnection')}</label>
|
||||
<InlineHelp label={t('roles.fields.modelConnection')} id="role-model-profile-help">{t('roles.fields.modelHelp')}</InlineHelp>
|
||||
</span>
|
||||
<select
|
||||
id="role-model-profile"
|
||||
aria-describedby={
|
||||
selectedModelProfileAvailable
|
||||
? 'role-model-profile-help'
|
||||
@@ -408,9 +413,6 @@ export function RolePromptSettingsSection({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small id="role-model-profile-help">
|
||||
{t('roles.fields.modelHelp')}
|
||||
</small>
|
||||
{!selectedModelProfileAvailable && (
|
||||
<small
|
||||
className="field-error"
|
||||
@@ -424,10 +426,14 @@ export function RolePromptSettingsSection({
|
||||
: t('roles.fields.modelFallback')}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('roles.fields.routingKeywords')}</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor="role-routing-keywords">{t('roles.fields.routingKeywords')}</label>
|
||||
<InlineHelp label={t('roles.fields.routingKeywords')} id="role-routing-keywords-help">{t('roles.fields.routingHelp')}</InlineHelp>
|
||||
</span>
|
||||
<textarea
|
||||
id="role-routing-keywords"
|
||||
aria-describedby={
|
||||
routingKeywordsError
|
||||
? 'role-routing-keywords-error role-routing-keywords-help'
|
||||
@@ -446,9 +452,6 @@ export function RolePromptSettingsSection({
|
||||
rows={3}
|
||||
value={draft.routingKeywordsText}
|
||||
/>
|
||||
<small id="role-routing-keywords-help">
|
||||
{t('roles.fields.routingHelp')}
|
||||
</small>
|
||||
{routingKeywordsError && (
|
||||
<small
|
||||
className="field-error"
|
||||
@@ -458,7 +461,7 @@ export function RolePromptSettingsSection({
|
||||
{routingKeywordsError}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="role-prompt-detail__actions">
|
||||
{draft.id ? (
|
||||
<DestructiveConfirmActions
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import {
|
||||
runtimeCustomizationLimits,
|
||||
type ContinueConfigurationPreset,
|
||||
@@ -669,12 +670,19 @@ export const RuntimeCustomizationSection = forwardRef<
|
||||
) : null}
|
||||
|
||||
{provider === 'opencode' && settings && snapshot ? (
|
||||
<label
|
||||
<div
|
||||
aria-busy={saving}
|
||||
className="field runtime-customization-editor"
|
||||
>
|
||||
<span>{t('runtime.customization.opencode.defaultAgent')}</span>
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor="opencode-default-agent">{t('runtime.customization.opencode.defaultAgent')}</label>
|
||||
<InlineHelp label={t('runtime.customization.opencode.defaultAgent')} id="opencode-default-agent-help">
|
||||
{t('runtime.customization.opencode.agentDescription')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
<select
|
||||
id="opencode-default-agent"
|
||||
aria-describedby="opencode-default-agent-help"
|
||||
aria-label={t(
|
||||
'runtime.customization.opencode.defaultAgent'
|
||||
)}
|
||||
@@ -705,10 +713,7 @@ export const RuntimeCustomizationSection = forwardRef<
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
{t('runtime.customization.opencode.agentDescription')}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{provider === 'continue' && settings && snapshot ? (
|
||||
|
||||
@@ -1120,7 +1120,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
configurable: true,
|
||||
value: DecodableBrandImage
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('选择 Logo'), {
|
||||
fireEvent.change(screen.getByLabelText('选择 Logo', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [
|
||||
new File(
|
||||
@@ -1304,13 +1304,11 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const contextWindow = await screen.findByLabelText(
|
||||
'上下文上限(可选)'
|
||||
)
|
||||
const contextWindow = await screen.findByRole('spinbutton', { name: '上下文上限(可选)' })
|
||||
expect(contextWindow).toHaveValue(null)
|
||||
expect(contextWindow).toHaveAttribute('min', '32')
|
||||
fireEvent.change(contextWindow, { target: { value: '256' } })
|
||||
const maximumOutput = screen.getByLabelText('Anthropic 最大输出')
|
||||
const maximumOutput = screen.getByRole('spinbutton', { name: 'Anthropic 最大输出' })
|
||||
expect(maximumOutput).toHaveValue(32)
|
||||
expect(maximumOutput).toHaveAttribute('min', '1')
|
||||
fireEvent.change(maximumOutput, { target: { value: '48' } })
|
||||
@@ -2770,6 +2768,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
name: '启用 Subagent 智能路由'
|
||||
})
|
||||
expect(smartRouting).not.toBeChecked()
|
||||
expect(screen.getByText(/仅在 Ask 模式/)).not.toBeVisible()
|
||||
expect(smartRouting).toHaveAccessibleDescription(/仅在 Ask 模式/)
|
||||
const routingHelp = screen.getByRole('button', { name: 'Subagent 智能路由' })
|
||||
expect(routingHelp.closest('label, summary')).toBeNull()
|
||||
fireEvent.click(routingHelp)
|
||||
expect(smartRouting).not.toBeChecked()
|
||||
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
|
||||
'自动选择 1 位专家'
|
||||
)
|
||||
@@ -3170,6 +3174,8 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(screen.getByText(locale === 'zh-CN'
|
||||
? `未配置完整的管理员预置;已保存配置中的实际回退:${resolvedName}。`
|
||||
: `No complete administrator preset; actual fallback in saved settings: ${resolvedName}.`)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: locale === 'zh-CN'
|
||||
? 'OpenAI 兼容模型连接' : 'OpenAI-compatible model connection' }))
|
||||
expect(source).toHaveAccessibleDescription(/Harness/)
|
||||
})
|
||||
|
||||
@@ -3435,7 +3441,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
render(<RuntimeCustomizationTestHarness provider="opencode" />)
|
||||
|
||||
const agent = await screen.findByLabelText('默认 Agent')
|
||||
const agent = await screen.findByRole('combobox', { name: '默认 Agent' })
|
||||
expect(agent).toHaveValue('planner')
|
||||
expect(
|
||||
screen.queryByText('OpenCode 原生能力已就绪')
|
||||
@@ -4180,12 +4186,18 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
const headers = await screen.findByLabelText('自定义请求头')
|
||||
const body = screen.getByLabelText('自定义请求体')
|
||||
const headers = await screen.findByRole('textbox', { name: '自定义请求头' })
|
||||
const body = screen.getByRole('textbox', { name: '自定义请求体' })
|
||||
expect(headers).toHaveValue('{}')
|
||||
expect(body).toHaveValue('{}')
|
||||
expect(headers).toHaveAttribute('maxlength')
|
||||
expect(body).toHaveAttribute('maxlength')
|
||||
const bodyHelp = screen.getByRole('button', { name: '自定义请求体' })
|
||||
expect(bodyHelp.closest('label, summary')).toBeNull()
|
||||
expect(screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)).not.toBeVisible()
|
||||
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
|
||||
fireEvent.click(bodyHelp)
|
||||
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
|
||||
expect(
|
||||
screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
|
||||
).toBeInTheDocument()
|
||||
@@ -4238,7 +4250,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
const headers = await screen.findByLabelText('自定义请求头')
|
||||
const headers = await screen.findByRole('textbox', { name: '自定义请求头' })
|
||||
fireEvent.change(headers, {
|
||||
target: { value: '{"Authorization":"replacement"}' }
|
||||
})
|
||||
@@ -4901,7 +4913,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
expect(rerankSwitch).not.toBeChecked()
|
||||
fireEvent.click(rerankSwitch)
|
||||
fireEvent.change(screen.getByLabelText('重排接口 URL'), {
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '重排接口 URL' }), {
|
||||
target: { value: 'https://rerank.example/v1/rerank' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('模型名称'), {
|
||||
@@ -5262,6 +5274,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
within(capabilityTabs).getByRole('tab', { name: 'Skills' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await screen.findByText('文档写作')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Skills' }))
|
||||
expect(
|
||||
screen.getByText(
|
||||
'支持直连模型、OpenCode、Continue 和 DeepSeek Harness'
|
||||
@@ -5946,7 +5959,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('路由关键词'), {
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '路由关键词' }), {
|
||||
target: { value: 'x' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
|
||||
@@ -5955,7 +5968,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(updateExpert).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('路由关键词'), {
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '路由关键词' }), {
|
||||
target: {
|
||||
value: ' TypeScript,代码 审查\nTYPESCRIPT '
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type {
|
||||
@@ -2195,10 +2196,10 @@ export function SettingsPanel({
|
||||
<div className="settings-section appearance-settings">
|
||||
<div className="settings-section__title">
|
||||
<SunMoon size={17} />
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>{t('appearance.theme.title')}</strong>
|
||||
<small>{t('appearance.theme.description')}</small>
|
||||
</div>
|
||||
<InlineHelp label={t('appearance.theme.title')}>{t('appearance.theme.description')}</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={t('appearance.theme.ariaLabel')}
|
||||
@@ -2242,10 +2243,10 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="settings-section appearance-settings">
|
||||
<div className="settings-section__title">
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>{t('appearance.language.title')}</strong>
|
||||
<small>{t('appearance.language.description')}</small>
|
||||
</div>
|
||||
<InlineHelp label={t('appearance.language.title')}>{t('appearance.language.description')}</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={t('appearance.language.ariaLabel')}
|
||||
@@ -2308,10 +2309,10 @@ export function SettingsPanel({
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title">
|
||||
<FolderOpen size={17} />
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>{t('runtime.workspace.title')}</strong>
|
||||
<small>{t('runtime.workspace.description')}</small>
|
||||
</div>
|
||||
<InlineHelp label={t('runtime.workspace.title')}>{t('runtime.workspace.description')}</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('runtime.workspace.directoryLabel')}</span>
|
||||
@@ -2836,9 +2837,15 @@ export function SettingsPanel({
|
||||
runtime: 'DeepSeek Harness'
|
||||
})}
|
||||
/>
|
||||
<label className="field">
|
||||
<span>{t('runtime.deepseekHarness.connection')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor="deepseek-harness-connection">{t('runtime.deepseekHarness.connection')}</label>
|
||||
<InlineHelp label={t('runtime.deepseekHarness.connection')} id="deepseek-harness-connection-description">
|
||||
{t('runtime.deepseekHarness.connectionDescription')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
<select
|
||||
id="deepseek-harness-connection"
|
||||
aria-label={`DeepSeek Harness ${t(
|
||||
'runtime.deepseekHarness.connection'
|
||||
)}`}
|
||||
@@ -2869,12 +2876,7 @@ export function SettingsPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small id="deepseek-harness-connection-description">
|
||||
{t(
|
||||
'runtime.deepseekHarness.connectionDescription'
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
<details className="settings-section">
|
||||
<summary>{t('runtime.advanced')}</summary>
|
||||
<p className="settings-panel__description">
|
||||
@@ -3342,9 +3344,16 @@ export function SettingsPanel({
|
||||
{t('model.profile.supportsImageInputDescription')}
|
||||
</small>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('model.profile.contextWindow')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor={`model-context-window-${profile.id}`}>{t('model.profile.contextWindow')} (K tokens)</label>
|
||||
<InlineHelp label={t('model.profile.contextWindow')} id={`model-context-window-help-${profile.id}`}>
|
||||
{t('model.profile.contextWindowDescription')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
<input
|
||||
id={`model-context-window-${profile.id}`}
|
||||
aria-describedby={`model-context-window-help-${profile.id}`}
|
||||
aria-label={t('model.profile.contextWindow')}
|
||||
inputMode="numeric"
|
||||
max={10_000}
|
||||
@@ -3365,14 +3374,18 @@ export function SettingsPanel({
|
||||
: profile.contextWindowTokens / 1_000
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
{t('model.profile.contextWindowDescription')}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
{profile.protocol === 'anthropic-messages' && (
|
||||
<label className="field">
|
||||
<span>{t('model.profile.maximumOutput')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor={`model-maximum-output-${profile.id}`}>{t('model.profile.maximumOutput')} (K tokens)</label>
|
||||
<InlineHelp label={t('model.profile.maximumOutput')} id={`model-maximum-output-help-${profile.id}`}>
|
||||
{t('model.profile.maximumOutputDescription')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
<input
|
||||
id={`model-maximum-output-${profile.id}`}
|
||||
aria-describedby={`model-maximum-output-help-${profile.id}`}
|
||||
aria-label={t('model.profile.maximumOutput')}
|
||||
inputMode="numeric"
|
||||
max={10_000}
|
||||
@@ -3392,18 +3405,22 @@ export function SettingsPanel({
|
||||
defaultAnthropicMaximumOutputTokens) / 1_000
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
{t('model.profile.maximumOutputDescription')}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{profile.protocol ===
|
||||
'openai-images-generations' && (
|
||||
<label className="field">
|
||||
<span>{t('model.profile.imageQuality')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor={`model-image-quality-${profile.id}`}>{t('model.profile.imageQuality')}</label>
|
||||
<InlineHelp label={t('model.profile.imageQuality')} id={`model-image-quality-help-${profile.id}`}>
|
||||
{t('model.profile.imageQualityDescription')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
<select
|
||||
id={`model-image-quality-${profile.id}`}
|
||||
aria-describedby={`model-image-quality-help-${profile.id}`}
|
||||
aria-label={t(
|
||||
'model.profile.imageQualityAriaLabel',
|
||||
{ name: modelProfileDisplayName(profile) }
|
||||
@@ -3429,14 +3446,17 @@ export function SettingsPanel({
|
||||
{t('model.profile.quality.high')}
|
||||
</option>
|
||||
</select>
|
||||
<small>
|
||||
{t('model.profile.imageQualityDescription')}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label className="field">
|
||||
<span>{t('model.profile.requestHeaders')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor={`model-request-headers-${profile.id}`}>{t('model.profile.requestHeaders')}</label>
|
||||
<InlineHelp label={t('model.profile.requestHeaders')} id={`model-request-headers-help-${profile.id}`}>
|
||||
{t('model.profile.requestHeadersDescription')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
<textarea
|
||||
id={`model-request-headers-${profile.id}`}
|
||||
aria-describedby={`model-request-headers-help-${profile.id}`}
|
||||
aria-label={t('model.profile.requestHeaders')}
|
||||
aria-invalid={
|
||||
@@ -3453,20 +3473,21 @@ export function SettingsPanel({
|
||||
spellCheck={false}
|
||||
value={profile.requestHeadersText}
|
||||
/>
|
||||
<small
|
||||
id={`model-request-headers-help-${profile.id}`}
|
||||
>
|
||||
{t('model.profile.requestHeadersDescription')}
|
||||
</small>
|
||||
{!requestHeadersValid && (
|
||||
<small className="field-error" role="alert">
|
||||
{t('model.profile.requestHeadersInvalid')}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('model.profile.requestBody')}</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor={`model-request-body-${profile.id}`}>{t('model.profile.requestBody')}</label>
|
||||
<InlineHelp label={t('model.profile.requestBody')} id={`model-request-body-help-${profile.id}`}>
|
||||
{t('model.profile.requestBodyDescription')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
<textarea
|
||||
id={`model-request-body-${profile.id}`}
|
||||
aria-describedby={`model-request-body-help-${profile.id}`}
|
||||
aria-label={t('model.profile.requestBody')}
|
||||
aria-invalid={
|
||||
@@ -3483,17 +3504,12 @@ export function SettingsPanel({
|
||||
spellCheck={false}
|
||||
value={profile.requestBodyText}
|
||||
/>
|
||||
<small
|
||||
id={`model-request-body-help-${profile.id}`}
|
||||
>
|
||||
{t('model.profile.requestBodyDescription')}
|
||||
</small>
|
||||
{!requestBodyValid && (
|
||||
<small className="field-error" role="alert">
|
||||
{t('model.profile.requestBodyInvalid')}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<p className="settings-warning">
|
||||
{t('model.profile.requestCustomizationWarning')}
|
||||
</p>
|
||||
@@ -3734,9 +3750,14 @@ export function SettingsPanel({
|
||||
/>
|
||||
<span>{t('model.rerank.enabled')}</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('model.rerank.endpoint')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor="model-rerank-endpoint">{t('model.rerank.endpoint')}</label>
|
||||
<InlineHelp label={t('model.rerank.endpoint')} id="model-rerank-endpoint-help">{t('model.rerank.endpointDescription')}</InlineHelp>
|
||||
</span>
|
||||
<input
|
||||
id="model-rerank-endpoint"
|
||||
aria-describedby="model-rerank-endpoint-help"
|
||||
aria-label={t('model.rerank.endpoint')}
|
||||
disabled={!knowledgeRerankEnabled}
|
||||
inputMode="url"
|
||||
@@ -3746,8 +3767,7 @@ export function SettingsPanel({
|
||||
placeholder="https://api.cohere.com/v1/rerank"
|
||||
value={knowledgeRerankEndpoint}
|
||||
/>
|
||||
<small>{t('model.rerank.endpointDescription')}</small>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('model.rerank.modelName')}</span>
|
||||
<input
|
||||
@@ -3837,21 +3857,24 @@ export function SettingsPanel({
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<div className="field">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={contextCompression.enabled}
|
||||
onChange={(event) =>
|
||||
setContextCompression((current) => ({
|
||||
...current,
|
||||
enabled: event.target.checked
|
||||
}))
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('contextControl.enabled')}</span>
|
||||
</label>
|
||||
<small>{t('contextControl.enabledDescription')}</small>
|
||||
<div className="inline-help-label">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
aria-describedby="context-compression-help"
|
||||
checked={contextCompression.enabled}
|
||||
onChange={(event) =>
|
||||
setContextCompression((current) => ({
|
||||
...current,
|
||||
enabled: event.target.checked
|
||||
}))
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('contextControl.enabled')}</span>
|
||||
</label>
|
||||
<InlineHelp label={t('contextControl.enabled')} id="context-compression-help">{t('contextControl.enabledDescription')}</InlineHelp>
|
||||
</div>
|
||||
<small>{t('contextControl.usageNotice')}</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3914,9 +3937,14 @@ export function SettingsPanel({
|
||||
})}
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('contextControl.summaryModel')}</span>
|
||||
<div className="field">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor="context-summary-model">{t('contextControl.summaryModel')}</label>
|
||||
<InlineHelp label={t('contextControl.summaryModel')} id="context-summary-model-help">{t('contextControl.summaryModelDescription')}</InlineHelp>
|
||||
</span>
|
||||
<select
|
||||
id="context-summary-model"
|
||||
aria-describedby="context-summary-model-help"
|
||||
aria-label={t('contextControl.summaryModel')}
|
||||
disabled={!contextCompression.enabled}
|
||||
onChange={(event) =>
|
||||
@@ -3951,8 +3979,7 @@ export function SettingsPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>{t('contextControl.summaryModelDescription')}</small>
|
||||
</label>
|
||||
</div>
|
||||
<p className="settings-panel__description">
|
||||
{t('contextControl.fixedTarget')}
|
||||
</p>
|
||||
@@ -4125,12 +4152,13 @@ export function SettingsPanel({
|
||||
<>
|
||||
<div className="settings-section subagent-routing-settings">
|
||||
<div className="settings-section__title">
|
||||
<div>
|
||||
<span className="inline-help-label">
|
||||
<strong>{t('roles.smartRouting.title')}</strong>
|
||||
<small>
|
||||
<InlineHelp label={t('roles.smartRouting.title')} id="subagent-smart-routing-help">
|
||||
{t('roles.smartRouting.description')}
|
||||
</small>
|
||||
</div>
|
||||
<p>{t('roles.smartRouting.help')}</p>
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
@@ -4144,9 +4172,6 @@ export function SettingsPanel({
|
||||
/>
|
||||
<span>{t('roles.smartRouting.enabled')}</span>
|
||||
</label>
|
||||
<small id="subagent-smart-routing-help">
|
||||
{t('roles.smartRouting.help')}
|
||||
</small>
|
||||
</div>
|
||||
<RolePromptSettingsSection
|
||||
defaultModelProfileId={savedRoleDefaultModelProfileId}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SettingsCategoryId
|
||||
} from './settings-categories'
|
||||
import { translateSettingsWarning } from './settings-warnings'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
|
||||
export function SettingsWarningList({
|
||||
warnings
|
||||
@@ -62,13 +63,15 @@ export function SettingsSectionHeader({
|
||||
description,
|
||||
error,
|
||||
headingLevel = 2,
|
||||
help,
|
||||
headingId,
|
||||
title
|
||||
}: {
|
||||
actions?: ReactNode
|
||||
description: string
|
||||
description?: string
|
||||
error?: string
|
||||
headingLevel?: 2 | 3
|
||||
help?: ReactNode
|
||||
headingId: string
|
||||
title: string
|
||||
}): React.JSX.Element {
|
||||
@@ -76,8 +79,8 @@ export function SettingsSectionHeader({
|
||||
return (
|
||||
<header className="settings-category-header">
|
||||
<div className="settings-category-header__content">
|
||||
<Heading id={headingId}>{title}</Heading>
|
||||
<p>{description}</p>
|
||||
<Heading id={headingId}>{help != null ? <span className="inline-help-label">{title}<InlineHelp label={title}>{help}</InlineHelp></span> : title}</Heading>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="settings-category-header__actions">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { SkillsSettingsSection } from './SkillsSettingsSection'
|
||||
import i18n from './i18n'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('moves runtime support to title help while keeping assignment notice and skill descriptions visible', async () => {
|
||||
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot: vi.fn().mockResolvedValue({ skills: [
|
||||
{ id: 'skill', name: 'Skill', source: 'builtin', enabled: true, description: 'Actual skill description', tags: [], assignments: [] }
|
||||
] }) } })
|
||||
render(<SkillsSettingsSection />)
|
||||
expect(await screen.findByText('Actual skill description')).toBeVisible()
|
||||
expect(screen.getByRole('heading', { name: 'Skills' }).closest('header')?.querySelector('p')).toBeNull()
|
||||
expect(screen.getByText(i18n.t('settingsSections:skills.notice'))).toBeVisible()
|
||||
expect(screen.queryByText(i18n.t('settingsSections:skills.description'))).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Skills' }))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(i18n.t('settingsSections:skills.description'))
|
||||
expect(screen.getByRole('tooltip').querySelector('button')).toBeNull()
|
||||
})
|
||||
@@ -93,7 +93,7 @@ export function SkillsSettingsSection(): React.JSX.Element {
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
description={t('skills.description')}
|
||||
help={t('skills.description')}
|
||||
error={error}
|
||||
headingLevel={3}
|
||||
headingId="skills-settings-heading"
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
|
||||
type HostDraft = {
|
||||
name: string
|
||||
@@ -121,6 +122,7 @@ export function SshHostDialog({
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const formErrorId = useId()
|
||||
const passwordId = useId()
|
||||
const api = window.goodbuddy.sshHosts
|
||||
const targetUnchanged = targetMatchesHost(draft, host)
|
||||
const canKeepPassword =
|
||||
@@ -652,9 +654,16 @@ export function SshHostDialog({
|
||||
)}
|
||||
{(!canKeepPassword ||
|
||||
passwordAction === 'replace') && (
|
||||
<label className="custom-task-dialog__field">
|
||||
<span>{t('sshHosts.fields.password')}</span>
|
||||
<div className="custom-task-dialog__field">
|
||||
<div className="inline-help-label">
|
||||
<label htmlFor={passwordId}>{t('sshHosts.fields.password')}</label>
|
||||
<InlineHelp label={t('sshHosts.fields.password')} id={`${passwordId}-help`}>
|
||||
{t('sshHosts.passwordHelp')}
|
||||
</InlineHelp>
|
||||
</div>
|
||||
<input
|
||||
id={passwordId}
|
||||
aria-describedby={`${passwordId}-notice ${passwordId}-help`}
|
||||
autoComplete="new-password"
|
||||
maxLength={
|
||||
SSH_HOST_LIMITS.maximumPasswordLength
|
||||
@@ -667,8 +676,8 @@ export function SshHostDialog({
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
<small>{t('sshHosts.passwordHelp')}</small>
|
||||
</label>
|
||||
<small id={`${passwordId}-notice`}>{t('sshHosts.passwordNotice')}</small>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -300,7 +300,19 @@ describe('SshHostsSettingsSection', () => {
|
||||
expect(validateAndSave).not.toHaveBeenCalled()
|
||||
expect(screen.getByText(new RegExp(fingerprint))).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^SSH 密码/u), {
|
||||
const passwordInput = screen.getByLabelText(/^SSH 密码/u, { selector: 'input' })
|
||||
const passwordHelp = screen.getByRole('button', { name: 'SSH 密码' })
|
||||
expect(passwordHelp.closest('label')).toBeNull()
|
||||
expect(screen.getByText(/密码用于 SSH 认证/)).toBeVisible()
|
||||
expect(screen.getByText(/由 Main 进程处理密码/)).not.toBeVisible()
|
||||
fireEvent.click(passwordHelp)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('不会写入命令行、日志或普通设置文件')
|
||||
expect(passwordInput).toHaveValue('')
|
||||
expect(validateAndSave).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
expect(passwordInput).toHaveAccessibleDescription(/密码用于 SSH 认证.*由 Main 进程处理密码/)
|
||||
fireEvent.change(passwordInput, {
|
||||
target: { value: 'private password' }
|
||||
})
|
||||
fireEvent.click(
|
||||
@@ -390,7 +402,7 @@ describe('SshHostsSettingsSection', () => {
|
||||
openNewHostDialog()
|
||||
fillConnectionDetails()
|
||||
await inspectAndConfirmFirstKey()
|
||||
const password = screen.getByLabelText(/^SSH 密码/u)
|
||||
const password = screen.getByLabelText(/^SSH 密码/u, { selector: 'input' })
|
||||
fireEvent.change(password, {
|
||||
target: { value: 'wrong password' }
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
LocalToolEnvironmentSettings,
|
||||
LocalToolEnvironmentSnapshot,
|
||||
@@ -340,9 +341,9 @@ export function ToolEnvironmentSettingsSection({
|
||||
return (
|
||||
<article className="tool-environment-card">
|
||||
<div className="tool-environment-card__header">
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<h3>{t(`toolEnvironment.runtimes.${kind}.title`)}</h3>
|
||||
<p>{t(`toolEnvironment.runtimes.${kind}.description`)}</p>
|
||||
<InlineHelp label={t(`toolEnvironment.runtimes.${kind}.title`)}>{t(`toolEnvironment.runtimes.${kind}.description`)}</InlineHelp>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
@@ -731,9 +732,9 @@ export function ToolEnvironmentSettingsSection({
|
||||
)}
|
||||
<section className="tool-environment-card">
|
||||
<div className="tool-environment-card__header">
|
||||
<div>
|
||||
<div className="inline-help-label">
|
||||
<h3>{t('toolEnvironment.downloadSource.title')}</h3>
|
||||
<p>{t('toolEnvironment.downloadSource.description')}</p>
|
||||
<InlineHelp label={t('toolEnvironment.downloadSource.title')}>{t('toolEnvironment.downloadSource.description')}</InlineHelp>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
|
||||
@@ -61,6 +61,11 @@ describe('UpdateSettingsSection', () => {
|
||||
name: 'GoodBuddy-0.9.0-windows-x64-setup.exe',
|
||||
size: 1024 * 1024,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'GoodBuddy-0.9.0-windows-x64-portable.zip',
|
||||
size: 1.5 * 1024 * 1024 * 1024,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -106,7 +111,7 @@ describe('UpdateSettingsSection', () => {
|
||||
name: '检查更新源'
|
||||
})
|
||||
const startupRow = startup.closest('label')
|
||||
const sourceRow = source.closest('label')
|
||||
const sourceRow = source.closest('.field')
|
||||
expect(source).toHaveValue('github')
|
||||
expect(sourceRow).toHaveClass('update-settings__source')
|
||||
expect(
|
||||
@@ -142,6 +147,8 @@ describe('UpdateSettingsSection', () => {
|
||||
expect(
|
||||
screen.getByText('GoodBuddy-0.9.0-windows-x64-setup.exe')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.5 GB')).toBeInTheDocument()
|
||||
|
||||
const feedbackTrigger = screen.getByRole('button', {
|
||||
name: '提交反馈'
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
UpdateSource,
|
||||
@@ -21,13 +22,7 @@ import {
|
||||
displayNetworkAwareErrorMessage
|
||||
} from './error-message'
|
||||
import { FeedbackDialog } from './FeedbackDialog'
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
import { formatModelPackageBytes } from './model-download-presentation'
|
||||
|
||||
export function UpdateSettingsSection({
|
||||
onNotify
|
||||
@@ -233,9 +228,14 @@ export function UpdateSettingsSection({
|
||||
<span>{t('updates.checkOnStartup')}</span>
|
||||
</label>
|
||||
|
||||
<label className="field update-settings__source">
|
||||
<span>{t('updates.source.label')}</span>
|
||||
<div className="field update-settings__source">
|
||||
<span className="inline-help-label">
|
||||
<label htmlFor="update-source">{t('updates.source.label')}</label>
|
||||
<InlineHelp label={t('updates.source.label')} id="update-source-help">{t('updates.source.description')}</InlineHelp>
|
||||
</span>
|
||||
<select
|
||||
id="update-source"
|
||||
aria-describedby="update-source-help"
|
||||
aria-label={t('updates.source.label')}
|
||||
disabled={
|
||||
!settings ||
|
||||
@@ -256,8 +256,7 @@ export function UpdateSettingsSection({
|
||||
{t('updates.source.options.mirror')}
|
||||
</option>
|
||||
</select>
|
||||
<small>{t('updates.source.description')}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="update-settings__actions">
|
||||
<button
|
||||
@@ -309,7 +308,7 @@ export function UpdateSettingsSection({
|
||||
{result.target.files.map((file) => (
|
||||
<li key={file.name}>
|
||||
<code>{file.name}</code>
|
||||
<span>{formatBytes(file.size)}</span>
|
||||
<span>{formatModelPackageBytes(file.size)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
|
||||
export type WorkspaceScope =
|
||||
| { kind: 'global' }
|
||||
@@ -142,6 +143,7 @@ export function PageHeader({
|
||||
description,
|
||||
eyebrow,
|
||||
headingId,
|
||||
help,
|
||||
icon,
|
||||
scope,
|
||||
title
|
||||
@@ -151,6 +153,7 @@ export function PageHeader({
|
||||
description?: ReactNode
|
||||
eyebrow?: string
|
||||
headingId: string
|
||||
help?: ReactNode
|
||||
icon?: ReactNode
|
||||
scope?: WorkspaceScope
|
||||
title: string
|
||||
@@ -169,7 +172,7 @@ export function PageHeader({
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<h1 id={headingId}>{title}</h1>
|
||||
<h1 id={headingId}>{help != null ? <span className="inline-help-label">{title}<InlineHelp label={title}>{help}</InlineHelp></span> : title}</h1>
|
||||
{scope && <ScopeBadge scope={scope} />}
|
||||
</div>
|
||||
{description && (
|
||||
|
||||
@@ -13,7 +13,8 @@ export const app = {
|
||||
manage: 'Manage Apps',
|
||||
title: 'Application Center', description: 'Open and reorder built-in applications, and manage optional apps and pins. Knowledge and Smart Heartbeat are always shown and can be moved.',
|
||||
localInference: 'Local Inference Monitor', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
|
||||
enableHelp: 'Pinning only controls the sidebar shortcut. Disabling preserves data and pins without stopping existing requests or background services.',
|
||||
pinHelp: 'Pinning only controls the sidebar shortcut.',
|
||||
disableHelp: 'Disabling preserves data and pins without stopping existing requests or background services.',
|
||||
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
|
||||
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
|
||||
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
|
||||
|
||||
@@ -38,8 +38,9 @@ export const settingsSections = {
|
||||
keep: 'Keep saved password',
|
||||
replace: 'Enter a new password'
|
||||
},
|
||||
passwordNotice: 'The password is used for SSH authentication and saved encrypted in system secure storage after validation succeeds.',
|
||||
passwordHelp:
|
||||
'The password is used only for this authentication attempt. After validation succeeds, the Main process writes it to system secure storage. It is never written to command-line arguments, logs, or regular settings files.',
|
||||
'The Main process handles the password. It is never written to command-line arguments, logs, or regular settings files.',
|
||||
credentialSources: {
|
||||
none: 'Not configured',
|
||||
encrypted: 'Encrypted by system secure storage',
|
||||
|
||||
@@ -436,10 +436,7 @@ export const workspace = {
|
||||
ready: 'Browser ready',
|
||||
failed: 'Browser operation failed',
|
||||
stopped: 'Browser stopped'
|
||||
},
|
||||
frameAlt: 'Live Agent browser view',
|
||||
noFrame: 'Could not capture the page',
|
||||
waitingFrame: 'Waiting for the first page frame…'
|
||||
}
|
||||
},
|
||||
errors: {
|
||||
workspacePreview: 'Could not preview the workspace file',
|
||||
|
||||
@@ -10,7 +10,8 @@ export const app = {
|
||||
manage: '管理应用',
|
||||
title: '应用中心', description: '打开和排列内置应用,管理可选应用的启用与常驻。知识库和智能心跳始终显示,位置可调整。',
|
||||
localInference: '本机推理监控', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
|
||||
enableHelp: '常驻只控制左侧捷径;关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
|
||||
pinHelp: '常驻只控制左侧捷径。',
|
||||
disableHelp: '关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
|
||||
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
|
||||
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
|
||||
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
|
||||
|
||||
@@ -33,8 +33,9 @@ export const settingsSections = {
|
||||
keep: '保留已保存密码',
|
||||
replace: '输入新密码'
|
||||
},
|
||||
passwordNotice: '密码用于 SSH 认证,验证成功后加密保存到系统安全存储。',
|
||||
passwordHelp:
|
||||
'密码仅用于本次认证,并在验证成功后由 Main 进程写入系统安全存储;不会写入命令行、日志或普通设置文件。',
|
||||
'由 Main 进程处理密码,不会写入命令行、日志或普通设置文件。',
|
||||
credentialSources: {
|
||||
none: '尚未配置',
|
||||
encrypted: '已由系统安全存储加密',
|
||||
|
||||
@@ -425,10 +425,7 @@ export const workspace = {
|
||||
ready: '浏览器已就绪',
|
||||
failed: '浏览器操作失败',
|
||||
stopped: '浏览器已停止'
|
||||
},
|
||||
frameAlt: 'Agent 实时浏览器画面',
|
||||
noFrame: '未能获取页面画面',
|
||||
waitingFrame: '等待首个页面画面…'
|
||||
}
|
||||
},
|
||||
errors: {
|
||||
workspacePreview: '工作区文件预览失败',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.inline-help-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.inline-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.inline-help:hover,
|
||||
.inline-help[aria-expanded="true"] {
|
||||
color: var(--accent);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.inline-help:active { background: var(--accent-subtle); }
|
||||
.inline-help:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.inline-help__content {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
font-family: var(--font-family-ui);
|
||||
font-size: var(--font-body);
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
text-align: start;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.inline-help__content > :first-child { margin-top: 0; }
|
||||
.inline-help__content > :last-child { margin-bottom: 0; }
|
||||
Reference in New Issue
Block a user