4 Commits
Author SHA1 Message Date
lofyer 1329251b5a chore: release 0.9.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / linux arm64 (push) Blocked by required conditions
Cross-platform packages / macos arm64 (push) Blocked by required conditions
Cross-platform packages / windows arm64 (push) Blocked by required conditions
Cross-platform packages / linux x64 (push) Blocked by required conditions
Cross-platform packages / macos x64 (push) Blocked by required conditions
Cross-platform packages / windows x64 (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
2026-08-14 18:28:22 +08:00
lofyer a5f1e31900 feat: preserve chat reading position 2026-08-14 18:27:55 +08:00
lofyer 57c57d232c chore: remove redundant Harness compatibility notice 2026-08-14 16:38:45 +08:00
lofyer a83f24a334 docs: clarify failed release recovery 2026-08-14 16:04:37 +08:00
13 changed files with 276 additions and 50 deletions
+17
View File
@@ -141,6 +141,23 @@ not require release notes.
displays the release notes matching the current interface language and displays the release notes matching the current interface language and
contains no button linking to a full release page. 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 Never create or push a release tag, and never push a previously created
release tag, before the release-note draft has received explicit approval. release tag, before the release-note draft has received explicit approval.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.1", "version": "0.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.1", "version": "0.9.2",
"license": "0BSD", "license": "0BSD",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "0.25.1", "@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.1", "version": "0.9.2",
"private": true, "private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes", "description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy", "desktopName": "GoodBuddy",
+24 -30
View File
@@ -1,6 +1,30 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"releases": [ "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 readers 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", "version": "0.9.1",
"releasedAt": "2026-08-14", "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", "version": "0.8.20",
"releasedAt": "2026-08-13", "releasedAt": "2026-08-13",
+109
View File
@@ -1168,6 +1168,115 @@ describe('App', () => {
expect(await screen.findByRole('status')).toBeVisible() 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 () => { it('requires an accessible confirmation before permanently deleting a conversation', async () => {
render(<App />) render(<App />)
const menuTrigger = screen.getByLabelText( const menuTrigger = screen.getByLabelText(
+79 -7
View File
@@ -1,4 +1,5 @@
import { import {
ArrowDown,
Bot, Bot,
Check, Check,
CheckCircle2, CheckCircle2,
@@ -525,6 +526,8 @@ function groupMessageBlocks(
return items return items
} }
const chatBottomProximity = 96
function MessageReasoning({ function MessageReasoning({
content, content,
streaming streaming
@@ -1593,12 +1596,42 @@ function App(): React.JSX.Element {
const hydratingArtifactIds = useRef(new Set<string>()) const hydratingArtifactIds = useRef(new Set<string>())
const knowledgeScopeInitialized = useRef(false) const knowledgeScopeInitialized = useRef(false)
const inputRef = useRef<HTMLTextAreaElement>(null) 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 sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null) const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef( const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>() 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 => { const closeNarrowSidebar = useCallback((): void => {
setSidebarOpen(false) setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus()) requestAnimationFrame(() => sidebarToggleRef.current?.focus())
@@ -3572,13 +3605,32 @@ function App(): React.JSX.Element {
useEffect(() => { useEffect(() => {
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ const scrollContext = `${view}:${activeId}`
top: scrollRef.current.scrollHeight, if (chatScrollContextRef.current !== scrollContext) {
behavior: 'auto' 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) return () => cancelAnimationFrame(frame)
}, [activeConversation?.messages]) }, [
activeConversation?.messages,
activeId,
updateChatScrollPosition,
view
])
const selectProject = (projectId: string): void => { const selectProject = (projectId: string): void => {
const project = projects.find((candidate) => candidate.id === projectId) const project = projects.find((candidate) => candidate.id === projectId)
@@ -4096,6 +4148,7 @@ function App(): React.JSX.Element {
runtime.capability === 'image-generation' ? '' : selectedExpertId runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode const workModeSnapshot = effectiveWorkMode
preparingConversations.current.add(conversationId) preparingConversations.current.add(conversationId)
chatPinnedToBottomRef.current = true
setInput('') setInput('')
updateAttachments([]) updateAttachments([])
const userMessage: Message = { const userMessage: Message = {
@@ -5266,7 +5319,13 @@ function App(): React.JSX.Element {
{view === 'chat' ? ( {view === 'chat' ? (
<PageShell variant="reading"> <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' && {activeProject?.kind === 'channel' &&
!activeConversation && ( !activeConversation && (
<EmptyState <EmptyState
@@ -5876,6 +5935,19 @@ function App(): React.JSX.Element {
))} ))}
</div> </div>
</section> </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"> <footer className="composer-wrap">
{activeProject?.kind === 'channel' ? ( {activeProject?.kind === 'channel' ? (
-3
View File
@@ -1411,9 +1411,6 @@ describe('SettingsPanel runtime files', () => {
expect( expect(
screen.getByText('开发者预览 · OpenAI 兼容') screen.getByText('开发者预览 · OpenAI 兼容')
).toBeInTheDocument() ).toBeInTheDocument()
expect(
screen.getByText(/公网地址必须使用 HTTPS/)
).toBeInTheDocument()
const harnessOverview = screen const harnessOverview = screen
.getByText('GoodBuddy 内置 DeepSeek Harness') .getByText('GoodBuddy 内置 DeepSeek Harness')
.closest<HTMLElement>('.runtime-overview') .closest<HTMLElement>('.runtime-overview')
-3
View File
@@ -1972,9 +1972,6 @@ export function SettingsPanel({
</small> </small>
</div> </div>
</div> </div>
<p className="settings-notice">
{t('runtime.deepseekHarness.compatibilityNotice')}
</p>
<RuntimeOverviewCard <RuntimeOverviewCard
detection={detection?.deepseekHarness} detection={detection?.deepseekHarness}
detectionLabel="DeepSeek Harness" detectionLabel="DeepSeek Harness"
@@ -226,6 +226,7 @@ export const app = {
} }
}, },
retry: 'Edit and send again', retry: 'Edit and send again',
scrollToBottom: 'Scroll to bottom',
status: { status: {
responseTruncated: 'The response was too long and was truncated locally', responseTruncated: 'The response was too long and was truncated locally',
savingImage: 'Image generated; saving the result', savingImage: 'Image generated; saving the result',
@@ -238,8 +238,6 @@ export const settings = {
selectorLabel: 'DeepSeek Harness (Preview)', selectorLabel: 'DeepSeek Harness (Preview)',
title: 'DeepSeek Harness', title: 'DeepSeek Harness',
previewDescription: 'Developer preview · OpenAI-compatible', 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: 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.', '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: managedSource:
@@ -219,6 +219,7 @@ export const app = {
} }
}, },
retry: '重新编辑并发送', retry: '重新编辑并发送',
scrollToBottom: '到底部',
status: { status: {
responseTruncated: '回答过长,已在本地截断显示', responseTruncated: '回答过长,已在本地截断显示',
savingImage: '图片已生成,正在保存结果', savingImage: '图片已生成,正在保存结果',
@@ -216,8 +216,6 @@ export const settings = {
selectorLabel: 'DeepSeek Harness(预览)', selectorLabel: 'DeepSeek Harness(预览)',
title: 'DeepSeek Harness', title: 'DeepSeek Harness',
previewDescription: '开发者预览 · OpenAI 兼容', previewDescription: '开发者预览 · OpenAI 兼容',
compatibilityNotice:
'支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接;公网地址必须使用 HTTPS,本机回环地址可使用 HTTP。',
description: description:
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。', '由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
managedSource: '管理员预置的 OpenAI 兼容连接', managedSource: '管理员预置的 OpenAI 兼容连接',
+42
View File
@@ -2968,7 +2968,15 @@ button > svg {
background: var(--success); background: var(--success);
} }
.chat-scroll-region {
position: relative;
min-height: 0;
overflow: hidden;
background: var(--surface-raised);
}
.chat { .chat {
height: 100%;
min-height: 0; min-height: 0;
padding: padding:
var(--page-gutter) var(--page-gutter)
@@ -2980,6 +2988,40 @@ button > svg {
scrollbar-width: thin; 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 { .welcome {
padding: 26px 0 30px; padding: 26px 0 30px;
text-align: center; text-align: center;