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:
@@ -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(() =>
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
}
|
||||
@@ -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: '默认单助手',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user