fix(composer): clarify DSH actions and keep stop confirmation inline

Name the browser and service controls explicitly for DSH. Keep confirmation and cancellation together in the composer action row, retain the interruption warning as a tooltip and accessible description, and show stopping progress on the confirmation button.

Validation: 20 focused tests passed; typecheck and lint passed.

Release note: 明确 DSH 网页和服务按钮名称,停止确认与取消按钮同行显示,避免确认区单独撑开聊天输入区。
This commit is contained in:
mesalogo
2026-10-04 00:17:51 +08:00
parent bf9850a6a9
commit 78beade289
8 changed files with 65 additions and 36 deletions
+4 -4
View File
@@ -10677,7 +10677,7 @@ describe("App", () => {
await screen.findByRole("textbox", { name: "向 GoodBuddy 提问" });
await waitFor(() => expect(api.settings.getRuntime).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "在终端中打开" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "在浏览器中打开" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "打开DSH Web" })).not.toBeInTheDocument();
});
it("opens local DS in the external browser without opening the workbar", async () => {
@@ -10688,13 +10688,13 @@ describe("App", () => {
}]);
vi.mocked(api.openRuntimeNativeClient).mockResolvedValue({ kind: "browser", serviceId: "ds-service" });
render(<App />);
const open = await screen.findByRole("button", { name: "在浏览器中打开" });
const open = await screen.findByRole("button", { name: "打开DSH Web" });
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledWith({ conversationId }));
fireEvent.click(open);
await waitFor(() => {
expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId });
expect(screen.getByRole("button", { name: "重新打开" })).toBeEnabled();
expect(screen.getByRole("button", { name: "停止此服务" })).toBeEnabled();
expect(screen.getByRole("button", { name: "打开DSH Web" })).toBeEnabled();
expect(screen.getByRole("button", { name: "停止DSH服务" })).toBeEnabled();
});
expect(screen.getByRole("button", { name: "切换助手工作栏" })).toHaveAttribute("aria-expanded", "false");
expect(api.terminal.create).not.toHaveBeenCalled();
@@ -49,17 +49,17 @@ describe('RuntimeNativeClientActions', () => {
it('refreshes the matching DS service on selection changes and stops only that service', async () => {
api.getRuntimeNativeClient.mockResolvedValueOnce({ serviceId: 'first-service' }).mockResolvedValue({ serviceId: 'second-service' })
const { rerender } = render(<RuntimeNativeClientActions {...props} browser />)
await screen.findByRole('button', { name: 'Reopen' })
await screen.findByRole('button', { name: 'Stop DSH service' })
rerender(<RuntimeNativeClientActions {...props} browser contextKey="second" />)
expect(screen.queryByRole('button', { name: 'Stop this service' })).not.toBeInTheDocument()
await screen.findByRole('button', { name: 'Reopen' })
fireEvent.click(screen.getByRole('button', { name: 'Stop this service' }))
expect(screen.queryByRole('button', { name: 'Stop DSH service' })).not.toBeInTheDocument()
await screen.findByRole('button', { name: 'Stop DSH service' })
fireEvent.click(screen.getByRole('button', { name: 'Stop DSH service' }))
expect(api.stopRuntimeNativeClient).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Stop service' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
await waitFor(() => {
expect(api.stopRuntimeNativeClient).toHaveBeenCalledWith({ serviceId: 'second-service' })
expect(screen.getByRole('button', { name: 'Open in browser' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'Stop this service' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open DSH Web' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'Stop DSH service' })).not.toBeInTheDocument()
})
})
@@ -68,13 +68,14 @@ describe('RuntimeNativeClientActions', () => {
render(<RuntimeNativeClientActions {...props} browser />)
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledTimes(1))
api.getRuntimeNativeClient.mockResolvedValue({ serviceId: 'ready-service' })
fireEvent.click(screen.getByRole('button', { name: 'Open in browser' }))
fireEvent.click(screen.getByRole('button', { name: 'Open DSH Web' }))
await waitFor(() => {
expect(props.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Browser could not open' })
expect(screen.getByRole('button', { name: 'Reopen' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Open DSH Web' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Stop DSH service' })).toBeEnabled()
})
api.openRuntimeNativeClient.mockResolvedValue({ kind: 'browser', serviceId: 'ready-service' })
fireEvent.click(screen.getByRole('button', { name: 'Reopen' }))
fireEvent.click(screen.getByRole('button', { name: 'Open DSH Web' }))
await waitFor(() => expect(api.openRuntimeNativeClient).toHaveBeenCalledTimes(2))
expect(props.onTerminal).not.toHaveBeenCalled()
})
@@ -87,18 +88,39 @@ describe('RuntimeNativeClientActions', () => {
rerender(<RuntimeNativeClientActions {...props} browser contextKey="second" />)
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledTimes(2))
await act(async () => finish({ serviceId: 'old-service' }))
expect(screen.queryByRole('button', { name: 'Stop this service' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Stop DSH service' })).not.toBeInTheDocument()
})
it('retains controls and reports stop errors for retry', async () => {
api.getRuntimeNativeClient.mockResolvedValue({ serviceId: 'service' })
api.stopRuntimeNativeClient.mockRejectedValueOnce(new Error('Stop failed'))
render(<RuntimeNativeClientActions {...props} browser />)
fireEvent.click(await screen.findByRole('button', { name: 'Stop this service' }))
fireEvent.click(screen.getByRole('button', { name: 'Stop service' }))
fireEvent.click(await screen.findByRole('button', { name: 'Stop DSH service' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
await waitFor(() => {
expect(props.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Stop failed' })
expect(screen.getByRole('button', { name: 'Stop service' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled()
})
})
it('cancels inline confirmation and shows stopping on the confirmation button', async () => {
await changeUiLocale('zh-CN')
api.getRuntimeNativeClient.mockResolvedValue({ serviceId: 'service' })
let finish!: () => void
api.stopRuntimeNativeClient.mockImplementation(() => new Promise(resolve => { finish = resolve }))
render(<RuntimeNativeClientActions {...props} browser />)
fireEvent.click(await screen.findByRole('button', { name: '停止DSH服务' }))
expect(screen.getByRole('button', { name: '确认' })).toHaveAccessibleDescription('停止后此服务的网页会断开,进行中的请求将中断。')
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('button', { name: '确认' })).not.toBeInTheDocument()
expect(api.stopRuntimeNativeClient).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '停止DSH服务' }))
fireEvent.click(screen.getByRole('button', { name: '确认' }))
expect(screen.getByRole('button', { name: '正在停止…' })).toBeDisabled()
expect(screen.getByRole('button', { name: '打开DSH Web' })).toBeDisabled()
expect(screen.getByRole('button', { name: '取消' })).toBeDisabled()
await act(async () => finish())
expect(screen.getByRole('button', { name: '打开DSH Web' })).toBeEnabled()
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument()
})
})
@@ -86,14 +86,16 @@ export function RuntimeNativeClientActions({ browser, contextKey, conversationId
return <>
<button className="composer-context-compact" type="button" disabled={!!pending} aria-busy={!!pending} onClick={() => void run()}>
{pending ? <LoaderCircle aria-hidden="true" className="context-chip__spinner" size={13} /> : browser ? <Globe aria-hidden="true" size={13} /> : <TerminalSquare aria-hidden="true" size={13} />}
{t(pending === 'stop' ? 'composer.nativeClient.stopping' : pending ? 'composer.nativeClient.opening' : serviceId ? 'composer.nativeClient.reopen' : browser ? 'composer.nativeClient.browser' : 'composer.nativeClient.terminal')}
{pending === 'open' ? <LoaderCircle aria-hidden="true" className="context-chip__spinner" size={13} /> : browser ? <Globe aria-hidden="true" size={13} /> : <TerminalSquare aria-hidden="true" size={13} />}
{t(pending === 'open' ? 'composer.nativeClient.opening' : browser ? 'composer.nativeClient.browser' : 'composer.nativeClient.terminal')}
</button>
{browser && serviceId && <button className="composer-context-compact danger-ghost" type="button" disabled={!!pending} onClick={() => setConfirmStop(serviceId)}>{t('composer.nativeClient.stop')}</button>}
{serviceId && confirmStop === serviceId && <span className="composer-native-client-confirm" role="group" aria-label={t('composer.nativeClient.stop')}>
<span>{t('composer.nativeClient.stopWarning')}</span>
<button type="button" className="secondary-button" disabled={!!pending} onClick={() => setConfirmStop(undefined)}>{t('composer.nativeClient.cancel')}</button>
<button type="button" className="danger-ghost" disabled={!!pending} onClick={() => void run(serviceId)}>{t('composer.nativeClient.confirmStop')}</button>
<button type="button" className="composer-context-compact danger-ghost" title={t('composer.nativeClient.stopWarning')} aria-description={t('composer.nativeClient.stopWarning')} aria-busy={pending === 'stop'} disabled={!!pending} onClick={() => void run(serviceId)}>
{pending === 'stop' && <LoaderCircle aria-hidden="true" className="context-chip__spinner" size={13} />}
{t(pending === 'stop' ? 'composer.nativeClient.stopping' : 'composer.nativeClient.confirmStop')}
</button>
<button type="button" className="composer-context-compact" disabled={!!pending} onClick={() => setConfirmStop(undefined)}>{t('composer.nativeClient.cancel')}</button>
</span>}
</>
}
+2 -2
View File
@@ -631,8 +631,8 @@ export const app = {
compactFailed: 'Context compaction failed'
},
nativeClient: {
terminal: 'Open in terminal', browser: 'Open in browser', opening: 'Opening...', stopping: 'Stopping...',
reopen: 'Reopen', stop: 'Stop this service', confirmStop: 'Stop service', cancel: 'Cancel',
terminal: 'Open in terminal', browser: 'Open DSH Web', opening: 'Opening...', stopping: 'Stopping...',
stop: 'Stop DSH service', confirmStop: 'Confirm', cancel: 'Cancel',
description: 'Independent session. Closing the page keeps the service running; stop it here or quit GoodBuddy.',
stopWarning: 'Stopping disconnects this service and interrupts its requests.',
stopped: 'Native client service stopped', failed: 'Could not open the native client'
+2 -2
View File
@@ -607,8 +607,8 @@ export const app = {
compactFailed: '上下文压缩失败'
},
nativeClient: {
terminal: '在终端中打开', browser: '在浏览器中打开', opening: '正在打开…', stopping: '正在停止…',
reopen: '重新打开', stop: '停止此服务', confirmStop: '停止服务', cancel: '取消',
terminal: '在终端中打开', browser: '打开DSH Web', opening: '正在打开…', stopping: '正在停止…',
stop: '停止DSH服务', confirmStop: '确认', cancel: '取消',
description: '独立会话;关闭网页后服务仍运行,可在此停止,退出 GoodBuddy 时停止。',
stopWarning: '停止后此服务的网页会断开,进行中的请求将中断。',
stopped: '原生客户端服务已停止', failed: '无法打开原生客户端'
+8 -4
View File
@@ -8871,19 +8871,23 @@ button > svg {
min-width: 0;
}
.composer-native-client-note,
.composer-native-client-confirm {
.composer-native-client-note {
flex-basis: 100%;
color: var(--text-muted);
}
.composer-native-client-confirm {
display: flex;
flex-wrap: wrap;
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-2);
}
.composer-native-client-confirm .composer-context-compact {
min-width: 0;
white-space: nowrap;
}
.composer-meta__error {
margin-left: auto;
}