feat: open local runtime clients from the composer

Add composer shortcuts for local Continue and OpenCode terminals and the official DeepSeek Harness Web client, using the current project and model bridge. Manage browser service reuse, stop actions and terminal cleanup. Upgrade DeepSeek Harness to 0.1.7-rc.2 and Cordis to 4.0.4.

Include the coordinated IPC handler extraction and related tests and documentation. Remote native-client coordinator wiring, managed Node consolidation, Ask MCP mappings and full desktop/package acceptance remain unfinished; this commit records the implemented local workflow and supporting SSH groundwork.

Validation: typecheck and lint passed; 267 focused tests passed and 5 skipped. No new real-provider calls were made for this commit. Prior real-model evidence and remaining acceptance work are documented in the feature progress files.

Release note: 本地项目选择 Continue 或 OpenCode 后,可从输入框下方打开原生终端;选择 DS 后,可打开使用当前项目和模型的官方 Web 客户端。客户端使用独立会话,DS 网页关闭后服务继续运行,可在 GoodBuddy 中停止;远程入口尚未完成。
This commit is contained in:
mesalogo
2026-09-27 08:38:00 +08:00
parent 1094433a7d
commit f81f342cde
71 changed files with 11943 additions and 2478 deletions
+81 -4
View File
@@ -24,6 +24,7 @@ import {
} from "../../shared/application-settings-contracts";
import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
import type { RuntimeNativeClientApi } from "./RuntimeNativeClientActions";
import { loadBrandingPreferences, saveBrandingPreferences } from "./branding";
import type {
AssistantProject,
@@ -198,7 +199,10 @@ const terminalSnapshot: TerminalSnapshot = {
error: null,
};
const api: DesktopApi = {
const api: DesktopApi & RuntimeNativeClientApi = {
openRuntimeNativeClient: vi.fn(async () => ({ kind: "terminal" as const, terminal: terminalSnapshot })),
getRuntimeNativeClient: vi.fn(async () => null),
stopRuntimeNativeClient: vi.fn(async () => undefined),
localInference: {
openSettings: vi.fn(async () => undefined),
getSnapshot: vi.fn(async () => ({ services: [], tasks: [] })),
@@ -1162,6 +1166,9 @@ describe("App", () => {
document.documentElement.style.colorScheme = "";
vi.clearAllMocks();
vi.mocked(api.magicNotes.search).mockReset().mockResolvedValue([]);
vi.mocked(api.openRuntimeNativeClient).mockReset().mockResolvedValue({ kind: "terminal", terminal: terminalSnapshot });
vi.mocked(api.getRuntimeNativeClient).mockReset().mockResolvedValue(null);
vi.mocked(api.stopRuntimeNativeClient).mockReset().mockResolvedValue();
vi.mocked(api.knowledge.getSnapshot).mockReset();
vi.mocked(api.knowledge.externalInstancesList).mockReset();
vi.mocked(api.agent.respondQuestion).mockReset().mockResolvedValue();
@@ -9663,6 +9670,77 @@ describe("App", () => {
);
});
it.each(["continue", "opencode"] as const)("opens the %s native terminal without compression or history and persists current settings", async (provider) => {
const conversationId = "00000000-0000-4000-8000-000000000731";
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: conversationId, projectId, title: "Native client", updatedAt: Date.now(),
runtimeSelection: { provider }, workMode: "execute", messages: [],
}]);
render(<App />);
const open = await screen.findByRole("button", { name: "在终端中打开" });
expect(open).toBeEnabled();
expect(screen.queryByRole("button", { name: "压缩上下文" })).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "向 GoodBuddy 提问" }), { target: { value: "Keep my draft" } });
fireEvent.click(open);
await waitFor(() => {
expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId });
expect(screen.getByRole("tab", { name: /终端 1/ })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "用户终端:终端 1" })).toBeInTheDocument();
});
expect(api.conversations.saveLocal).toHaveBeenCalledWith([{
header: expect.objectContaining({ id: conversationId, runtimeSelection: { provider }, workMode: "execute" }), messages: [],
}]);
expect(screen.getByRole("textbox", { name: "向 GoodBuddy 提问" })).toHaveValue("Keep my draft");
expect(api.terminal.create).not.toHaveBeenCalled();
});
it("hides the native client shortcut for the built-in model", async () => {
render(<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();
});
it("opens local DS in the external browser without opening the workbar", async () => {
const conversationId = "00000000-0000-4000-8000-000000000731";
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: conversationId, projectId, title: "DS client", updatedAt: Date.now(),
runtimeSelection: { provider: "deepseek-harness" }, messages: [],
}]);
vi.mocked(api.openRuntimeNativeClient).mockResolvedValue({ kind: "browser", serviceId: "ds-service" });
render(<App />);
const open = await screen.findByRole("button", { name: "在浏览器中打开" });
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: "切换助手工作栏" })).toHaveAttribute("aria-expanded", "false");
expect(api.terminal.create).not.toHaveBeenCalled();
});
it("keeps a late native terminal without opening the workbar after switching conversations", async () => {
const conversationId = "00000000-0000-4000-8000-000000000731";
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ id: conversationId, projectId, title: "Original native", updatedAt: 2, runtimeSelection: { provider: "continue" }, messages: [] },
{ id: "00000000-0000-4000-8000-000000000732", projectId, title: "Other conversation", updatedAt: 1, messages: [] },
]);
let finish!: (result: Awaited<ReturnType<RuntimeNativeClientApi['openRuntimeNativeClient']>>) => void;
vi.mocked(api.openRuntimeNativeClient).mockImplementation(() => new Promise(resolve => { finish = resolve; }));
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "在终端中打开" }));
await waitFor(() => expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId }));
fireEvent.click(screen.getByRole("button", { name: /^Other conversation/ }));
await waitFor(() => expect(screen.queryByRole("button", { name: "正在打开…" })).not.toBeInTheDocument());
await act(async () => finish({ kind: "terminal", terminal: terminalSnapshot }));
expect(screen.getByRole("button", { name: "切换助手工作栏" })).toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
expect(await screen.findByRole("tab", { name: /终端 1/ })).toHaveAttribute("aria-selected", "false");
});
it("manually compacts Continue context and persists the summary state", async () => {
const conversationId = "00000000-0000-4000-8000-000000000731";
const messages = [
@@ -9755,11 +9833,10 @@ describe("App", () => {
name: "压缩上下文",
});
expect(compactContext.parentElement).toHaveClass(
"composer-meta",
"composer-meta--with-context-compact",
"composer-meta__actions",
);
expect(compactContext.parentElement?.firstElementChild).toBe(
compactContext,
screen.getByRole("button", { name: "在终端中打开" }),
);
fireEvent.click(compactContext);
await waitFor(() =>
+46 -5
View File
@@ -3,6 +3,8 @@ import { MagicNotesPanel } from './MagicNotesPanel';
import { AnchoredMenu } from './AnchoredMenu';
import { useMagicNoteDraft } from './use-magic-note-draft';
import type { MagicNoteSource } from '../../shared/magic-notes-contracts';
import { RuntimeNativeClientActions } from "./RuntimeNativeClientActions";
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
import LocalInferencePage from "./LocalInferencePage";
import { ApplicationMenu } from './ApplicationMenu';
import { AttachmentResultButton } from './AttachmentResultButton';
@@ -2440,6 +2442,7 @@ function App(): React.JSX.Element {
loadPrimarySidebarWidth,
);
const [primarySidebarResizing, setPrimarySidebarResizing] = useState(false);
const [nativeTerminals, setNativeTerminals] = useState<{ terminal: TerminalSnapshot; focus: boolean }[]>([]);
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280,
);
@@ -8903,6 +8906,32 @@ function App(): React.JSX.Element {
activeRuntimeSelection?.provider === "continue") &&
runtimeNativeSnapshot?.context.manualCompact === true;
const nativeClientAvailable = activeRuntimeSelection?.provider === "continue" ||
activeRuntimeSelection?.provider === "opencode" ||
(activeRuntimeSelection?.provider === "deepseek-harness" && activeProject?.executionSpace.kind === "local");
const nativeClientContextKey = useMemo(() => JSON.stringify([
activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings,
]), [activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings]);
const prepareNativeClientConversation = async (): Promise<string> => {
let conversation = activeConversation;
if (!conversation) {
conversation = await new Promise<Conversation>((resolve, reject) => {
if (!startNewConversation(activeProjectId || undefined, { ready: resolve })) {
reject(new Error(t("notices.channelConversationAutomatic")));
}
});
setActiveId(conversation.id);
}
// Save the current selection before Main resolves the launch from its conversation ID.
const header = toLocalConversationHeader({ ...conversation, runtimeSelection: activeRuntimeSelection, workMode });
const operation = conversationPersistenceQueueRef.current.then(() =>
window.goodbuddy.conversations.saveLocal([{ header, messages: [] }]),
);
conversationPersistenceQueueRef.current = operation.catch(() => undefined);
await operation;
return conversation.id;
};
const composerContextMetrics = useMemo(() => {
if (
!activeConversation ||
@@ -10995,12 +11024,22 @@ function App(): React.JSX.Element {
</div>
</div>
<div
className={`composer-meta${
runtimeContextCompactAvailable
? " composer-meta--with-context-compact"
: ""
}`}
className="composer-meta"
>
<div className="composer-meta__actions">
{nativeClientAvailable && <RuntimeNativeClientActions
browser={activeRuntimeSelection?.provider === "deepseek-harness"}
contextKey={nativeClientContextKey}
conversationId={activeConversation?.id}
prepareConversation={prepareNativeClientConversation}
notify={notify}
onTerminal={(terminal) => {
const focus = activeProjectIdRef.current === activeProjectId &&
activeConversationIdRef.current === activeId && viewRef.current === "chat";
setNativeTerminals(current => [...current, { terminal, focus }]);
if (focus) setAssistantSidebarOpen(true);
}}
/>}
{runtimeContextCompactAvailable && (
<button
className="composer-context-compact"
@@ -11023,6 +11062,7 @@ function App(): React.JSX.Element {
: t("composer.context.compact")}
</button>
)}
</div>
{composerContextMetrics && (
<div
className={`composer-context-meter${
@@ -11863,6 +11903,7 @@ function App(): React.JSX.Element {
notesPanel={<MagicNotesPanel state={noteDraft} active={assistantSidebarOpen && magicNotesEnabled} commentMode={applicationSettings?.magicNoteCommentMode} commentFormat={applicationSettings?.magicNoteCommentFormat} onNotify={notify} onOpenSource={openNoteSource}
onCancelCapture={() => requestAnimationFrame(() => { const trigger = noteCaptureTrigger.current; (trigger?.isConnected && !trigger.closest('[hidden], [inert]') ? trigger : noteTitleMenuRef.current)?.focus(); })}
onOpenWorkspace={(noteId, entryId) => requestWorkspaceLeave('magic-notes', () => { setNotesNavigation({ noteId, entryId, requestId: Date.now() }); commitView('magic-notes'); })} />}
nativeTerminals={nativeTerminals}
activeConversationId={activeId}
conversationStats={statsConversation && executionStats.conversation ? {
conversationId: statsConversation.id,
@@ -125,6 +125,7 @@ export type RightAssistantSidebarProps = {
notesOpenRequest?: number
notesPanel?: React.ReactNode
onBeforeCloseNotes?: () => Promise<boolean>
nativeTerminals?: readonly { terminal: TerminalSnapshot; focus: boolean }[]
open: boolean
tab: AssistantSidebarTab
approvals: PendingSidebarApproval[]
@@ -815,6 +816,7 @@ export function RightAssistantSidebar({
notesOpenRequest = 0,
notesPanel,
onBeforeCloseNotes,
nativeTerminals,
open,
tab,
approvals,
@@ -1060,6 +1062,18 @@ export function RightAssistantSidebar({
const [terminalSnapshots, setTerminalSnapshots] = useState<
Record<string, TerminalSnapshot>
>({})
const [attachedNativeTerminalCount, setAttachedNativeTerminalCount] = useState(0)
if (nativeTerminals && nativeTerminals.length > attachedNativeTerminalCount) {
const additions = nativeTerminals.slice(attachedNativeTerminalCount)
setAttachedNativeTerminalCount(nativeTerminals.length)
setWorkbarInstances(current => [...current, ...additions.map(({ terminal }) => ({
id: terminal.sessionId, appId: 'terminal' as const, title: terminal.title, targetRef: terminal.target
}))])
setTerminalSessionIds(current => ({ ...current, ...Object.fromEntries(additions.map(({ terminal }) => [terminal.sessionId, terminal.sessionId])) }))
setTerminalSnapshots(current => ({ ...current, ...Object.fromEntries(additions.map(({ terminal }) => [terminal.sessionId, terminal])) }))
const focused = additions.findLast(entry => entry.focus)
if (focused) setActiveWorkbarInstanceId(focused.terminal.sessionId)
}
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
useState<{
instance: WorkbarTabInstance
@@ -0,0 +1,104 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RuntimeNativeClientActions, type RuntimeNativeClientApi } from './RuntimeNativeClientActions'
import { changeUiLocale } from './i18n'
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
const terminal: TerminalSnapshot = {
sessionId: '00000000-0000-4000-8000-000000000123', target: { type: 'local' },
title: 'Continue', targetLabel: 'Local', state: 'running', shell: 'continue',
workingDirectory: '/project', size: { cols: 80, rows: 24 }, lastSequence: 0, exit: null, error: null
}
const api = {
openRuntimeNativeClient: vi.fn<RuntimeNativeClientApi['openRuntimeNativeClient']>(),
getRuntimeNativeClient: vi.fn<RuntimeNativeClientApi['getRuntimeNativeClient']>(),
stopRuntimeNativeClient: vi.fn<RuntimeNativeClientApi['stopRuntimeNativeClient']>()
}
const props = {
browser: false, contextKey: 'first', conversationId: 'conversation',
prepareConversation: vi.fn(async () => 'conversation'), onTerminal: vi.fn(), notify: vi.fn()
}
beforeEach(async () => {
vi.resetAllMocks()
await changeUiLocale('en-US')
props.prepareConversation.mockResolvedValue('conversation')
api.getRuntimeNativeClient.mockResolvedValue(null)
api.stopRuntimeNativeClient.mockResolvedValue()
vi.stubGlobal('goodbuddy', api)
})
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
describe('RuntimeNativeClientActions', () => {
it('saves the conversation before launch and prevents duplicate clicks while opening', async () => {
let finish!: (result: Awaited<ReturnType<RuntimeNativeClientApi['openRuntimeNativeClient']>>) => void
api.openRuntimeNativeClient.mockImplementation(() => new Promise(resolve => { finish = resolve }))
render(<RuntimeNativeClientActions {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'Open in terminal' }))
await waitFor(() => expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId: 'conversation' }))
expect(props.prepareConversation.mock.invocationCallOrder[0]).toBeLessThan(api.openRuntimeNativeClient.mock.invocationCallOrder[0]!)
const pending = screen.getByRole('button', { name: 'Opening...' })
expect(pending).toBeDisabled()
fireEvent.click(pending)
expect(api.openRuntimeNativeClient).toHaveBeenCalledTimes(1)
await act(async () => finish({ kind: 'terminal', terminal }))
expect(props.onTerminal).toHaveBeenCalledWith(terminal)
expect(screen.getByRole('button', { name: 'Open in terminal' })).toBeEnabled()
})
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' })
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(api.stopRuntimeNativeClient).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Stop service' }))
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()
})
})
it('reopens DS through Main and recovers the stop control after browser-open failure', async () => {
api.openRuntimeNativeClient.mockRejectedValueOnce(new Error('Browser could not open'))
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' }))
await waitFor(() => {
expect(props.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Browser could not open' })
expect(screen.getByRole('button', { name: 'Reopen' })).toBeEnabled()
})
api.openRuntimeNativeClient.mockResolvedValue({ kind: 'browser', serviceId: 'ready-service' })
fireEvent.click(screen.getByRole('button', { name: 'Reopen' }))
await waitFor(() => expect(api.openRuntimeNativeClient).toHaveBeenCalledTimes(2))
expect(props.onTerminal).not.toHaveBeenCalled()
})
it('does not display a service returned for an old selection', async () => {
let finish!: (result: { serviceId: string }) => void
api.getRuntimeNativeClient.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const { rerender } = render(<RuntimeNativeClientActions {...props} browser />)
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledTimes(1))
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()
})
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' }))
await waitFor(() => {
expect(props.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Stop failed' })
expect(screen.getByRole('button', { name: 'Stop service' })).toBeEnabled()
})
})
})
@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react'
import { Globe, LoaderCircle, TerminalSquare } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
export type RuntimeNativeClientApi = {
openRuntimeNativeClient: (input: { conversationId: string }) => Promise<
{ kind: 'terminal'; terminal: TerminalSnapshot } | { kind: 'browser'; serviceId: string }
>
getRuntimeNativeClient: (input: { conversationId: string }) => Promise<{ serviceId: string } | null>
stopRuntimeNativeClient: (input: { serviceId: string }) => Promise<void>
}
type Props = {
browser: boolean
contextKey: string
conversationId?: string
prepareConversation: () => Promise<string>
onTerminal: (terminal: TerminalSnapshot) => void
notify: (notification: { tone: 'error' | 'success'; message: string }) => void
}
export function RuntimeNativeClientActions({ browser, contextKey, conversationId, prepareConversation, onTerminal, notify }: Props): React.JSX.Element {
const { t } = useTranslation('app')
const [pending, setPending] = useState<'open' | 'stop'>()
const busy = useRef(false)
const lookupGeneration = useRef(0)
const [service, setService] = useState<{ key: string; serviceId: string }>()
const [confirmStop, setConfirmStop] = useState<string>()
const current = useRef({ contextKey, prepareConversation, notify })
useEffect(() => { current.current = { contextKey, prepareConversation, notify } })
const api = window.goodbuddy as typeof window.goodbuddy & RuntimeNativeClientApi
const serviceId = service?.key === contextKey ? service.serviceId : undefined
useEffect(() => {
if (!browser || !conversationId) return
let cancelled = false
const refresh = async (): Promise<void> => {
const generation = ++lookupGeneration.current
try {
const id = await current.current.prepareConversation()
if (cancelled) return
const result = await api.getRuntimeNativeClient({ conversationId: id })
if (!cancelled && !busy.current && lookupGeneration.current === generation) setService(result ? { key: contextKey, ...result } : undefined)
} catch (error) {
if (!cancelled) current.current.notify({ tone: 'error', message: error instanceof Error ? error.message : t('composer.nativeClient.failed') })
}
}
void refresh()
window.addEventListener('focus', refresh)
return () => { cancelled = true; window.removeEventListener('focus', refresh) }
}, [api, browser, contextKey, conversationId, t])
const run = async (stopServiceId?: string): Promise<void> => {
if (busy.current) return
busy.current = true
lookupGeneration.current += 1
setPending(stopServiceId ? 'stop' : 'open')
try {
if (stopServiceId) {
await api.stopRuntimeNativeClient({ serviceId: stopServiceId })
setService(previous => previous?.serviceId === stopServiceId ? undefined : previous)
setConfirmStop(undefined)
notify({ tone: 'success', message: t('composer.nativeClient.stopped') })
} else {
const id = await prepareConversation()
const result = await api.openRuntimeNativeClient({ conversationId: id })
if (result.kind === 'terminal') onTerminal(result.terminal)
else if (current.current.contextKey === contextKey) setService({ key: contextKey, serviceId: result.serviceId })
}
} catch (error) {
notify({ tone: 'error', message: error instanceof Error ? error.message : t('composer.nativeClient.failed') })
// A browser-open failure can leave a ready service that the user can stop or reopen.
if (browser && !stopServiceId && current.current.contextKey === contextKey) {
try {
const id = await prepareConversation()
const result = await api.getRuntimeNativeClient({ conversationId: id })
if (current.current.contextKey === contextKey) setService(result ? { key: contextKey, ...result } : undefined)
} catch { /* Keep the original operation error. */ }
}
} finally {
busy.current = false
setPending(undefined)
}
}
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')}
</button>
{browser && serviceId && <button className="composer-context-compact danger-ghost" type="button" disabled={!!pending} onClick={() => setConfirmStop(serviceId)}>{t('composer.nativeClient.stop')}</button>}
{browser && <span className="composer-native-client-note">{t('composer.nativeClient.description')}</span>}
{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>
</span>}
</>
}
+3 -3
View File
@@ -202,7 +202,7 @@ const detectAgentRuntimes = vi.fn<
deepseekHarness: {
available: true,
path: 'bundled://deepseek-harness',
version: '0.1.2-rc.1',
version: '0.1.7-rc.2',
source: 'bundled',
detail: '内置 Harness Adapter 已就绪'
}
@@ -2974,11 +2974,11 @@ describe('SettingsPanel runtime files', () => {
)
).toHaveClass('runtime-overview__path')
expect(
within(harnessOverview).getByText('0.1.2-rc.1')
within(harnessOverview).getByText('0.1.7-rc.2')
).toBeInTheDocument()
expect(
within(harnessOverview).getByText(
'内置 DeepSeek Harness 0.1.2-rc.1 已就绪'
'内置 DeepSeek Harness 0.1.7-rc.2 已就绪'
)
).toBeInTheDocument()
expect(
@@ -344,7 +344,7 @@ describe('WorkspacePrimitives', () => {
)
})
it('floats Runtime context compaction without shifting the composer', () => {
it('wraps native client and compaction actions without overlapping composer metadata', () => {
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
)
@@ -352,10 +352,10 @@ describe('WorkspacePrimitives', () => {
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
)
expect(stylesheet).toMatch(
/\.composer-meta--with-context-compact\s*\{[^}]*padding-left:\s*calc\(/u
/\.composer-meta__actions\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;/u
)
expect(stylesheet).toMatch(
/\.composer-context-compact\s*\{[^}]*position:\s*absolute;[^}]*bottom:\s*0;[^}]*left:\s*0;/u
expect(stylesheet).not.toMatch(
/\.composer-context-compact\s*\{[^}]*position:\s*absolute;/u
)
})
@@ -570,6 +570,13 @@ export const app = {
nothingToCompact: 'There is no earlier conversation history to compact',
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',
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'
},
experts: {
general: 'General assistant',
generalDescription: 'Default single assistant',
@@ -546,6 +546,13 @@ export const app = {
nothingToCompact: '当前没有可压缩的较早对话历史',
compactFailed: '上下文压缩失败'
},
nativeClient: {
terminal: '在终端中打开', browser: '在浏览器中打开', opening: '正在打开…', stopping: '正在停止…',
reopen: '重新打开', stop: '停止此服务', confirmStop: '停止服务', cancel: '取消',
description: '独立会话;关闭网页后服务仍运行,可在此停止,退出 GoodBuddy 时停止。',
stopWarning: '停止后此服务的网页会断开,进行中的请求将中断。',
stopped: '原生客户端服务已停止', failed: '无法打开原生客户端'
},
experts: {
general: '通用助手',
generalDescription: '默认单助手',
+21 -8
View File
@@ -7911,10 +7911,26 @@ button > svg {
line-height: 1.45;
}
.composer-meta--with-context-compact {
padding-left: calc(
var(--composer-context-compact-width) + var(--space-3)
);
.composer-meta__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-right: auto;
min-width: 0;
}
.composer-native-client-note,
.composer-native-client-confirm {
flex-basis: 100%;
color: var(--text-muted);
}
.composer-native-client-confirm {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.composer-meta__error {
@@ -7931,11 +7947,8 @@ button > svg {
}
.composer-context-compact {
position: absolute;
bottom: 0;
left: 0;
display: inline-flex;
width: var(--composer-context-compact-width);
min-width: var(--composer-context-compact-width);
min-height: 26px;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-control);