Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
5b53919
fix: update pi runtime for GPT-6 models
QAyong Sep 26, 2026
09a3bae
feat(desktop): configure close-to-tray behavior
QAyong Sep 26, 2026
54dedd4
feat(buddy): move turn process disclosure to agent header
QAyong Sep 26, 2026
636d90a
chore: exclude private spec and add PR screenshot
QAyong Sep 26, 2026
3e06c67
feat: swap resource and chat panels
QAyong Sep 27, 2026
2e779f0
feat: support session references in chat
QAyong Sep 27, 2026
9232157
fix: collapse chat pane after swapping context panel
QAyong Sep 27, 2026
a8367f7
fix: persist workbench after task deletion
QAyong Sep 27, 2026
26b4d0a
fix: discard workbench views for unavailable tasks
QAyong Sep 27, 2026
29ab1ee
revert: remove chat session references
QAyong Sep 27, 2026
c5fdc63
perf(buddy): cache skill metadata during chat prep
QAyong Sep 27, 2026
dd6abad
docs: restore previous feature specs
QAyong Sep 27, 2026
425434e
merge: restore pre-reset Buddy work
QAyong Sep 27, 2026
a26a168
fix(buddy): refine pane switching and process divider
QAyong Sep 27, 2026
d28ad7a
docs: add architecture overview
QAyong Sep 27, 2026
1e29d32
docs: add spec-005 default permission mode
QAyong Sep 27, 2026
105dd50
merge: sync upstream/master 至 v0.9.2 (#202-#206)
QAyong Sep 28, 2026
7cb01f0
style(buddy): 修复阻断发布预检的 5 处 lint 错误
QAyong Sep 28, 2026
1154be8
feat(buddy): add default permission mode for new tasks
QAyong Sep 28, 2026
fca564b
fix(windows): allow read-only ACL grants on private directories
QAyong Sep 28, 2026
7349628
test(buddy): cover notification run branch activation
QAyong Sep 28, 2026
92147b3
docs: 新增 Spec-006 空间侧栏与底部功能入口草案
QAyong Sep 28, 2026
f7a0e09
docs: 按反馈修订 Spec-006 为合并侧栏方案
QAyong Sep 28, 2026
d896109
feat(buddy): 合并主导航侧栏到空间侧栏
QAyong Sep 28, 2026
175dfb3
feat(buddy): 侧栏底部入口右侧显示应用版本号
QAyong Sep 28, 2026
a919843
feat(buddy): 侧栏底部改为通知入口贴右,去掉版本号
QAyong Sep 28, 2026
beac3d5
feat(buddy): 移除会话栏边缘的悬停折叠按钮
QAyong Sep 28, 2026
0e99def
fix(buddy): 恢复侧栏拖拽时禁用的宽度动画规则
QAyong Sep 28, 2026
7abc2c1
feat(buddy): 侧栏底部入口改用自绘悬浮提示
QAyong Sep 28, 2026
ca556bc
feat(buddy): 任务分组支持直接新建对话并调整侧栏头部入口
QAyong Sep 28, 2026
ca0156a
feat(buddy): 空间内的会话支持置顶
QAyong Sep 28, 2026
a42c527
feat(buddy): 会话行与空间行统一行内操作
QAyong Sep 28, 2026
93c17f9
feat(buddy): 会话列表默认显示 5 条并支持展开其余
QAyong Sep 28, 2026
6712520
feat(buddy): 标题栏功能按钮补充自绘悬浮提示
QAyong Sep 28, 2026
92275e9
docs: 同步 Spec-006 到实现现状
QAyong Sep 28, 2026
0fea3a9
feat(buddy): 空间更多菜单移除重复的新任务项
QAyong Sep 28, 2026
072c6ca
feat(buddy): 设置新增 AI 身份,可自定义聊天窗口的头像与名称
QAyong Sep 28, 2026
7ce0e61
docs: 新增 Spec-007 聊天窗口 AI 身份(头像与名称)
QAyong Sep 28, 2026
c962ea8
(pr) 合并侧栏、底部入口与侧栏交互细化(Spec-006)
QAyong Sep 28, 2026
2577073
fix(buddy): keep interleaved thought folds independent
QAyong Sep 29, 2026
acb3609
feat(buddy): support referenced chat sessions
QAyong Sep 29, 2026
c396ed1
style(buddy): align session reference lint
QAyong Sep 29, 2026
deb2528
fix(buddy): align desktop session reference behavior
QAyong Sep 29, 2026
f3f5f7c
docs: attach session reference preview screenshots
QAyong Sep 29, 2026
9b53af5
style(buddy): use system text style for chat AI name
QAyong Sep 29, 2026
5ea0f60
Merge branch 'codex/feature-006-fixed-primary-navigation'
QAyong Sep 29, 2026
f7d7dc1
Merge branch 'codex/desktop-session-reference'
QAyong Sep 29, 2026
48671af
fix(buddy): restore behaviors lost in branch merge
QAyong Sep 29, 2026
12e9bb6
fix(buddy): preserve optional agent identity behavior
QAyong Sep 29, 2026
324a822
Merge remote-tracking branch 'upstream/master'
QAyong Sep 29, 2026
2040a37
fix(buddy): restore user avatar in settings
QAyong Sep 29, 2026
369dbac
feat(buddy): default space name to selected folder
QAyong Sep 29, 2026
515b77d
fix(buddy): stabilize desktop notification navigation
QAyong Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .playwright/scripts/__tests__/extensionContributions.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@
test('plugin-owned preferences activate contributions, yield native content and restore across restart', async ({ buddy }) => {
const instance = await buddy.createInstance('plugin-contributions')
let { app, page, diagnostics } = await instance.launch()
const navigate = async name => page.locator('.desktop-app-sidebar').getByRole('button', { name, exact: true }).click()
const backToTasks = async () => {
const back = page.locator('.desktop-back-to-tasks')
if (await back.count())
await back.click()
}
const openModule = async name => {

Check failure on line 13 in .playwright/scripts/__tests__/extensionContributions.e2e.mjs

View workflow job for this annotation

GitHub Actions / Workspace checks

Expected parentheses around arrow function argument having a body with curly braces
await backToTasks()
await page.locator('.desktop-sidebar-footer').getByRole('button', { name, exact: true }).click()
}
const openPluginPage = async id => page.locator(`[data-extension-id="${id}"]`).getByRole('button', { name: '打开', exact: true }).click()
const nativeFooter = () => page.locator('.desktop-chat-composer__disclaimer')
const pluginFrame = async (name, file) => {
let result
Expand All @@ -24,7 +33,7 @@
async function install(name) {
const directory = path.join(instance.home, 'fixtures', name)
const id = await writeContributionPlugin(directory, name)
await navigate('插件')
await openModule('插件')
await app.evaluate(({ dialog }, directory) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] })
}, directory)
Expand All @@ -35,14 +44,15 @@
await expect(page.getByText('界面呈现', { exact: true })).toHaveCount(0)
}
async function preference(name, enabled) {
await navigate(`Settings ${name}`)
await openModule('插件')
await openPluginPage(`tests.${name}`)
const settings = await pluginFrame(name, 'settings')
await settings.getByRole('checkbox', { name: 'Enable footer' }).setChecked(enabled)
await expect(settings.getByRole('status')).toHaveText(enabled ? 'Enabled' : 'Disabled')
await navigate('任务')
await backToTasks()
}
await install('alpha')
await navigate('任务')
await backToTasks()
await expect(nativeFooter()).toBeVisible()
await preference('alpha', true)
const alpha = await pluginFrame('alpha', 'footer')
Expand Down Expand Up @@ -72,7 +82,7 @@

await instance.stop()
;({ app, page, diagnostics } = await instance.launch())
await navigate('任务')
await backToTasks()
const restoredAlpha = await pluginFrame('alpha', 'footer')
await expect.poll(() => restoredAlpha.evaluate(() => window.fixture.visible)).toBe(true)
await page.evaluate(() => window.lexoraDesktop.extensions.enable('tests.alpha', false))
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Vue + Electron 承载桌面体验,独立的 TypeScript Runtime 承载本地 Ag

## 本地开发

需要 Node.js 26+、pnpm 11.5+ 与 Rust 工具链;平台依赖见[构建说明](packaging/buddy/README.md)。在仓库根目录运行:
需要 Node.js 26+、pnpm 12.5.1+ 与 Rust 工具链;平台依赖见[构建说明](packaging/buddy/README.md)。在仓库根目录运行:

```bash
pnpm --filter @uselexora/lexora --filter '@uselexora/lexora-buddy...' --filter @uselexora/lexora-website install --frozen-lockfile
Expand All @@ -83,6 +83,17 @@ pnpm dev
pnpm dev:website
```

### 使用真实数据预览 Buddy UI

Buddy 未打包开发模式默认使用独立的数据目录 `~/.lexora-dev`。如果需要让最新代码继承已安装 Lexora 的真实配置、会话和授权,可在启动前使用 `stable` profile:

```powershell
$env:LEXORA_BUDDY_PROFILE = "stable"
pnpm dev:buddy
```

开发预览地址为 <http://localhost:1420/>。`stable` profile 会直接使用正式数据目录 `~/.lexora`;Windows 下通常对应 `C:\Users\<用户名>\.lexora`。它不会复制或迁移数据,因此开发版产生的配置、会话或授权写入也可能影响正式版。该方式适合 UI 预览,执行会修改数据的功能测试前应先备份真实数据,或改用默认的开发目录。

## 参与贡献

发现问题,欢迎[提个 Issue](https://github.com/useLexora/Lexora/issues)。若你已经动手实现了自己的想法,欢迎按[贡献说明](CONTRIBUTING.md)提交 PR,和大家分享你的方案。
Expand Down
103 changes: 83 additions & 20 deletions apps/buddy/electron/main/DesktopNotificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export interface DesktopNotificationInput {
}

export interface DesktopSystemNotification {
close: () => void
onClick: (listener: () => void) => void
onClose: (listener: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => void) => void
onFailed: (listener: () => void) => void
show: () => void
}

Expand All @@ -28,6 +31,7 @@ export interface DesktopNotificationServiceOptions {
'notificationsEnabled' | 'notifyWhenFocused'
>
isWindowFocused: () => boolean
onError: (error: unknown) => void
openTarget: (target: DesktopNotificationTarget) => Promise<void> | void
request: DesktopRuntimeGateway['request']
}
Expand All @@ -50,19 +54,23 @@ const BODY_LABELS = {
export class DesktopNotificationService {
readonly #options: DesktopNotificationServiceOptions
readonly #shownEvents = new Set<string>()
readonly #pendingEvents = new Set<string>()
// Keep native click handlers alive independently of the selected conversation.
readonly #notifications = new Map<string, DesktopSystemNotification>()
#disposed = false

constructor(options: DesktopNotificationServiceOptions) {
this.#options = options
}

async handle(notification: { method: string, params: unknown }): Promise<void> {
if (notification.method !== 'run.event')
if (this.#disposed || notification.method !== 'run.event')
return
const event = runsRequestSchemas.runStateEvent.safeParse(notification.params)
if (!event.success)
return
const eventKey = `${event.data.runId}:${event.data.sequence}:${event.data.type}`
if (this.#shownEvents.has(eventKey))
if (this.#shownEvents.has(eventKey) || this.#pendingEvents.has(eventKey))
return
if (!shouldShowDesktopNotification({
eventType: event.data.type,
Expand All @@ -72,24 +80,79 @@ export class DesktopNotificationService {
return
}

const run = runsResponseSchemas.run.parse(
await this.#options.request('runs.get', { runId: event.data.runId }),
)
const conversation = conversationResponseSchemas.conversation.parse(
await this.#options.request('conversations.get', { conversationId: run.conversationId }),
)
const labels = BODY_LABELS[this.#options.getLanguage()]
const systemNotification = this.#options.createNotification({
body: labels[event.data.type as keyof Omit<typeof labels, 'untitled'>],
title: conversation.title?.trim() || labels.untitled,
})
systemNotification.onClick(() => {
void this.#options.openTarget({
conversationId: run.conversationId,
runId: run.id,
this.#pendingEvents.add(eventKey)
try {
const run = runsResponseSchemas.run.parse(
await this.#options.request('runs.get', { runId: event.data.runId }),
)
if (this.#disposed)
return
const conversation = conversationResponseSchemas.conversation.parse(
await this.#options.request('conversations.get', { conversationId: run.conversationId }),
)
if (this.#disposed)
return
const labels = BODY_LABELS[this.#options.getLanguage()]
const systemNotification = this.#options.createNotification({
body: labels[event.data.type as keyof Omit<typeof labels, 'untitled'>],
title: conversation.title?.trim() || labels.untitled,
})
})
systemNotification.show()
this.#shownEvents.add(eventKey)
this.#notifications.set(eventKey, systemNotification)
const release = () => {
if (this.#notifications.get(eventKey) !== systemNotification)
return false
this.#notifications.delete(eventKey)
return true
}
systemNotification.onClick(() => {
if (!release())
return
void Promise.resolve().then(() => {
if (!this.#disposed) {
return this.#options.openTarget({
conversationId: run.conversationId,
runId: run.id,
})
}
}).catch(error => this.#options.onError(error))
})
systemNotification.onClose((reason) => {
// A timed-out/hidden Windows banner can still be clicked in Action Center.
if (reason === 'userCanceled')
release()
})
systemNotification.onFailed(() => {
if (release())
this.#shownEvents.delete(eventKey)
})
this.#shownEvents.add(eventKey)
try {
systemNotification.show()
}
catch (error) {
release()
this.#shownEvents.delete(eventKey)
throw error
}
}
finally {
this.#pendingEvents.delete(eventKey)
}
}

dispose(): void {
this.#disposed = true
const notifications = [...this.#notifications.values()]
this.#notifications.clear()
this.#shownEvents.clear()
this.#pendingEvents.clear()
for (const notification of notifications) {
try {
notification.close()
}
catch (error) {
this.#options.onError(error)
}
}
}
}
Loading
Loading