Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1329251b5a | ||
|
|
a5f1e31900 | ||
|
|
57c57d232c | ||
|
|
a83f24a334 |
@@ -141,6 +141,23 @@ not require release notes.
|
||||
displays the release notes matching the current interface language and
|
||||
contains no button linking to a full release page.
|
||||
|
||||
When recovering from a version tag whose workflow never published a GitHub
|
||||
Release and its assets:
|
||||
|
||||
- If the approved source and release metadata do not need to change, rerun the
|
||||
failed jobs for the same immutable tag instead of creating another tag.
|
||||
- If a code or metadata change requires a higher version and a new tag, carry
|
||||
the failed candidate's approved user-facing notes forward into the recovery
|
||||
version, then remove the superseded failed version's entry from
|
||||
`resources/release-notes.json`.
|
||||
- The packaged first-open modal must show that carried-forward content only
|
||||
once under the recovery version. Never retain both the failed version and
|
||||
its cumulative recovery copy, because users upgrading across them would see
|
||||
duplicate content.
|
||||
- Never remove the packaged history for a version that successfully published
|
||||
a GitHub Release. Verify the failed release state before treating an entry as
|
||||
superseded.
|
||||
|
||||
Never create or push a release tag, and never push a previously created
|
||||
release tag, before the release-note draft has received explicit approval.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.9.2",
|
||||
"releasedAt": "2026-08-14",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"features": [
|
||||
"新增长对话“到底部”浮动按钮;阅读较早消息时,流式回复会保持当前位置,只有停留在底部附近时才自动跟随最新内容,并遵循系统的减少动态效果偏好。"
|
||||
],
|
||||
"fixes": [
|
||||
"精简 DeepSeek Harness 设置说明,移除与连接配置重复的兼容性提示。",
|
||||
"修复应用内 0.9.0 与 0.9.1 更新说明内容重复的问题。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"features": [
|
||||
"Added a floating “Scroll to bottom” control for long conversations; streamed responses now preserve the reader’s position unless they remain near the bottom, and the control respects the system reduced-motion preference."
|
||||
],
|
||||
"fixes": [
|
||||
"Simplified the DeepSeek Harness settings by removing a compatibility notice that duplicated the connection guidance.",
|
||||
"Fixed duplicate in-app release-note content between versions 0.9.0 and 0.9.1."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.9.1",
|
||||
"releasedAt": "2026-08-14",
|
||||
@@ -33,36 +57,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.9.0",
|
||||
"releasedAt": "2026-08-14",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"features": [
|
||||
"新增 DeepSeek Harness 开发者预览 Runtime,支持安全的 OpenAI 兼容 Chat Completions 连接、任意模型名称、Skills 与自定义 MCP,并保留 Ask 只读、Execute 授权、取消和工作区沙箱边界。",
|
||||
"新增自然语言配置工具,助手可查看脱敏设置并规划应用偏好、Skill 与 MCP 变更;配置只在原生审批通过后应用,凭据不会暴露给模型。",
|
||||
"GoodBuddy 原创源代码现以 0BSD 许可证开放,并补充英文项目总览以及自动化、会话监督、分区记忆、并行实验和持续学习等产品规划文档。"
|
||||
],
|
||||
"fixes": [
|
||||
"修复模型工具调用期间流式推理内容可能折叠或不可见的问题,并让推理区域在生成时自动跟随最新内容。",
|
||||
"修复从通道入口打开设置时未定位到所选企业微信、钉钉或微信页面的问题,并更正微信二维码扫码提示。",
|
||||
"优化简体中文界面的系统字体、字号和行高,改善 Windows 与 macOS 上的小字号可读性和排版一致性。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"features": [
|
||||
"Added the DeepSeek Harness preview Runtime with secure OpenAI-compatible Chat Completions, arbitrary model names, Skills, and custom MCP, while preserving read-only Ask, authorized Execute, cancellation, and workspace sandbox boundaries.",
|
||||
"Added natural-language configuration tools that let the assistant inspect sanitized settings and plan changes to app preferences, Skills, and MCP; changes apply only after native approval without exposing credentials to models.",
|
||||
"Released GoodBuddy's original source under the 0BSD License and expanded the English overview and product plans for automation, conversation supervision, partitioned memory, parallel experiments, and continuous learning."
|
||||
],
|
||||
"fixes": [
|
||||
"Fixed streamed reasoning becoming hidden during model tool calls, and kept the reasoning panel following the latest content while generation is in progress.",
|
||||
"Fixed channel shortcuts opening the wrong settings page for WeCom, DingTalk, or WeChat, and corrected the WeChat QR-code scan guidance.",
|
||||
"Improved Simplified Chinese typography with platform-native UI fonts, refined sizes, and line heights for clearer, more consistent text on Windows and macOS."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.8.20",
|
||||
"releasedAt": "2026-08-13",
|
||||
|
||||
@@ -1168,6 +1168,115 @@ describe('App', () => {
|
||||
expect(await screen.findByRole('status')).toBeVisible()
|
||||
})
|
||||
|
||||
it('offers a floating control when more messages remain below', async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000401',
|
||||
projectId,
|
||||
title: '长会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000402',
|
||||
role: 'assistant',
|
||||
content: '较早的会话内容',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
const { container } = render(<App />)
|
||||
|
||||
expect(await screen.findByText('较早的会话内容')).toBeInTheDocument()
|
||||
const chat = container.querySelector<HTMLElement>('.chat')
|
||||
if (!chat) {
|
||||
throw new Error('Missing chat scroll container')
|
||||
}
|
||||
Object.defineProperties(chat, {
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollHeight: { configurable: true, value: 1_200 },
|
||||
scrollTop: { configurable: true, writable: true, value: 100 }
|
||||
})
|
||||
const scrollTo = vi.fn()
|
||||
chat.scrollTo = scrollTo
|
||||
|
||||
fireEvent.scroll(chat)
|
||||
const scrollButton = screen.getByRole('button', {
|
||||
name: '到底部'
|
||||
})
|
||||
expect(scrollButton).toHaveAttribute(
|
||||
'aria-controls',
|
||||
'chat-message-list'
|
||||
)
|
||||
expect(scrollButton).toHaveAttribute('title', '到底部')
|
||||
expect(scrollButton).toHaveTextContent('')
|
||||
|
||||
fireEvent.click(scrollButton)
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||
top: 1_200,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
chat.scrollTop = 750
|
||||
fireEvent.scroll(chat)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '到底部' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the reader position while a response continues below', async () => {
|
||||
render(<App />)
|
||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '继续生成较长回复' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
await act(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
)
|
||||
)
|
||||
|
||||
const chat = document.querySelector<HTMLElement>('.chat')
|
||||
if (!chat) {
|
||||
throw new Error('Missing chat scroll container')
|
||||
}
|
||||
Object.defineProperties(chat, {
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollHeight: { configurable: true, value: 1_200 },
|
||||
scrollTop: { configurable: true, writable: true, value: 100 }
|
||||
})
|
||||
const scrollTo = vi.fn()
|
||||
chat.scrollTo = scrollTo
|
||||
fireEvent.scroll(chat)
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: '新增的回复内容'
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('新增的回复内容')).toBeInTheDocument()
|
||||
await act(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
)
|
||||
)
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '到底部' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires an accessible confirmation before permanently deleting a conversation', async () => {
|
||||
render(<App />)
|
||||
const menuTrigger = screen.getByLabelText(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
Bot,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
@@ -525,6 +526,8 @@ function groupMessageBlocks(
|
||||
return items
|
||||
}
|
||||
|
||||
const chatBottomProximity = 96
|
||||
|
||||
function MessageReasoning({
|
||||
content,
|
||||
streaming
|
||||
@@ -1593,12 +1596,42 @@ function App(): React.JSX.Element {
|
||||
const hydratingArtifactIds = useRef(new Set<string>())
|
||||
const knowledgeScopeInitialized = useRef(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
const chatPinnedToBottomRef = useRef(true)
|
||||
const chatScrollContextRef = useRef(`${view}:${activeId}`)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const conversationActionTriggerRefs = useRef(
|
||||
new Map<string, HTMLButtonElement>()
|
||||
)
|
||||
const updateChatScrollPosition = useCallback((): void => {
|
||||
const scrollContainer = scrollRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
const distanceFromBottom =
|
||||
scrollContainer.scrollHeight -
|
||||
scrollContainer.scrollTop -
|
||||
scrollContainer.clientHeight
|
||||
const atBottom = distanceFromBottom <= chatBottomProximity
|
||||
chatPinnedToBottomRef.current = atBottom
|
||||
setShowScrollToBottom(!atBottom)
|
||||
}, [])
|
||||
const scrollChatToBottom = useCallback((): void => {
|
||||
const scrollContainer = scrollRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
chatPinnedToBottomRef.current = true
|
||||
const reduceMotion =
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: reduceMotion ? 'auto' : 'smooth'
|
||||
})
|
||||
}, [])
|
||||
const closeNarrowSidebar = useCallback((): void => {
|
||||
setSidebarOpen(false)
|
||||
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
|
||||
@@ -3572,13 +3605,32 @@ function App(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'auto'
|
||||
})
|
||||
const scrollContext = `${view}:${activeId}`
|
||||
if (chatScrollContextRef.current !== scrollContext) {
|
||||
chatScrollContextRef.current = scrollContext
|
||||
chatPinnedToBottomRef.current = true
|
||||
}
|
||||
const scrollContainer = scrollRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
if (chatPinnedToBottomRef.current) {
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'auto'
|
||||
})
|
||||
setShowScrollToBottom(false)
|
||||
return
|
||||
}
|
||||
updateChatScrollPosition()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [activeConversation?.messages])
|
||||
}, [
|
||||
activeConversation?.messages,
|
||||
activeId,
|
||||
updateChatScrollPosition,
|
||||
view
|
||||
])
|
||||
|
||||
const selectProject = (projectId: string): void => {
|
||||
const project = projects.find((candidate) => candidate.id === projectId)
|
||||
@@ -4096,6 +4148,7 @@ function App(): React.JSX.Element {
|
||||
runtime.capability === 'image-generation' ? '' : selectedExpertId
|
||||
const workModeSnapshot = effectiveWorkMode
|
||||
preparingConversations.current.add(conversationId)
|
||||
chatPinnedToBottomRef.current = true
|
||||
setInput('')
|
||||
updateAttachments([])
|
||||
const userMessage: Message = {
|
||||
@@ -5266,7 +5319,13 @@ function App(): React.JSX.Element {
|
||||
|
||||
{view === 'chat' ? (
|
||||
<PageShell variant="reading">
|
||||
<section className="chat" ref={scrollRef}>
|
||||
<div className="chat-scroll-region">
|
||||
<section
|
||||
className="chat"
|
||||
id="chat-message-list"
|
||||
onScroll={updateChatScrollPosition}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{activeProject?.kind === 'channel' &&
|
||||
!activeConversation && (
|
||||
<EmptyState
|
||||
@@ -5876,6 +5935,19 @@ function App(): React.JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{showScrollToBottom && (
|
||||
<button
|
||||
aria-controls="chat-message-list"
|
||||
aria-label={t('chat.scrollToBottom')}
|
||||
className="chat-scroll-to-bottom"
|
||||
onClick={scrollChatToBottom}
|
||||
title={t('chat.scrollToBottom')}
|
||||
type="button"
|
||||
>
|
||||
<ArrowDown aria-hidden="true" size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="composer-wrap">
|
||||
{activeProject?.kind === 'channel' ? (
|
||||
|
||||
@@ -1411,9 +1411,6 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(
|
||||
screen.getByText('开发者预览 · OpenAI 兼容')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/公网地址必须使用 HTTPS/)
|
||||
).toBeInTheDocument()
|
||||
const harnessOverview = screen
|
||||
.getByText('GoodBuddy 内置 DeepSeek Harness')
|
||||
.closest<HTMLElement>('.runtime-overview')
|
||||
|
||||
@@ -1972,9 +1972,6 @@ export function SettingsPanel({
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-notice">
|
||||
{t('runtime.deepseekHarness.compatibilityNotice')}
|
||||
</p>
|
||||
<RuntimeOverviewCard
|
||||
detection={detection?.deepseekHarness}
|
||||
detectionLabel="DeepSeek Harness"
|
||||
|
||||
@@ -226,6 +226,7 @@ export const app = {
|
||||
}
|
||||
},
|
||||
retry: 'Edit and send again',
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
status: {
|
||||
responseTruncated: 'The response was too long and was truncated locally',
|
||||
savingImage: 'Image generated; saving the result',
|
||||
|
||||
@@ -238,8 +238,6 @@ export const settings = {
|
||||
selectorLabel: 'DeepSeek Harness (Preview)',
|
||||
title: 'DeepSeek Harness',
|
||||
previewDescription: 'Developer preview · OpenAI-compatible',
|
||||
compatibilityNotice:
|
||||
'Supports OpenAI-compatible Chat Completions connections with an API key. Public endpoints must use HTTPS; loopback endpoints may use HTTP.',
|
||||
description:
|
||||
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.',
|
||||
managedSource:
|
||||
|
||||
@@ -219,6 +219,7 @@ export const app = {
|
||||
}
|
||||
},
|
||||
retry: '重新编辑并发送',
|
||||
scrollToBottom: '到底部',
|
||||
status: {
|
||||
responseTruncated: '回答过长,已在本地截断显示',
|
||||
savingImage: '图片已生成,正在保存结果',
|
||||
|
||||
@@ -216,8 +216,6 @@ export const settings = {
|
||||
selectorLabel: 'DeepSeek Harness(预览)',
|
||||
title: 'DeepSeek Harness',
|
||||
previewDescription: '开发者预览 · OpenAI 兼容',
|
||||
compatibilityNotice:
|
||||
'支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接;公网地址必须使用 HTTPS,本机回环地址可使用 HTTP。',
|
||||
description:
|
||||
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
|
||||
managedSource: '管理员预置的 OpenAI 兼容连接',
|
||||
|
||||
@@ -2968,7 +2968,15 @@ button > svg {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.chat-scroll-region {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.chat {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding:
|
||||
var(--page-gutter)
|
||||
@@ -2980,6 +2988,40 @@ button > svg {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: var(--space-3);
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: 50%;
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-card);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
transform: translateX(-50%);
|
||||
transition:
|
||||
border-color var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out,
|
||||
color var(--motion-fast) ease-out,
|
||||
opacity var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom:hover {
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom:active {
|
||||
background: var(--accent-selected);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
padding: 26px 0 30px;
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user