diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 9ce4060a..23910f47 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -14,7 +14,7 @@ name: Build Octop FPK # # Jobs: # version → ensure-image → fpk(Docker 版 .fpk,仅打包 compose) -# ↘ native(本地版 .fpk)→ release(固定版 + 滚动 latest) +# ↘ native(本地版 .fpk)→ release(按版本号发布 fnos-) on: workflow_dispatch: @@ -32,9 +32,8 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.ver.outputs.version }} - iter: ${{ steps.iter.outputs.iter }} - tag: ${{ steps.iter.outputs.tag }} - pkg_ver: ${{ steps.iter.outputs.pkg_ver }} + tag: ${{ steps.ver.outputs.tag }} + pkg_ver: ${{ steps.ver.outputs.pkg_ver }} image: ${{ steps.ver.outputs.image }} steps: - uses: actions/checkout@v4 @@ -49,24 +48,9 @@ jobs: IMAGE="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" echo "version=$VER" >> "$GITHUB_OUTPUT" echo "image=$IMAGE" >> "$GITHUB_OUTPUT" - - - name: Compute iteration - id: iter - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VER=${{ steps.ver.outputs.version }} - # 查询已存在的 fnos-vanilla--NN releases,取最大序号 +1。 - echo "[version] existing releases matching fnos-vanilla-${VER}-NN:" - gh release list --repo "${{ github.repository }}" --limit 100 --json tagName | python3 -c 'import sys,json; [print(" "+r.get("tagName","")) for r in json.load(sys.stdin)]' || true - EXISTING=$(gh release list --repo "${{ github.repository }}" --limit 100 --json tagName | python3 -c 'import sys,json,re; d=json.load(sys.stdin); pat=re.compile(r"fnos-vanilla-[0-9]+\.[0-9]+\.[0-9]+-([0-9]+)$"); nums=[int(pat.match(r.get("tagName","")).group(1)) for r in d if pat.match(r.get("tagName",""))]; print(max(nums) if nums else 0)') - MAX=${EXISTING:-0} - NEXT=$((MAX + 1)) - ITER=$(printf "%02d" "$NEXT") - echo "iter=$ITER" >> "$GITHUB_OUTPUT" - echo "tag=fnos-vanilla-${VER}-${ITER}" >> "$GITHUB_OUTPUT" - echo "pkg_ver=${VER}-${ITER}" >> "$GITHUB_OUTPUT" - echo "[version] VER=$VER, EXISTING_MAX=$MAX, ITER=$ITER, TAG=fnos-vanilla-${VER}-${ITER}" + echo "tag=fnos-${VER}" >> "$GITHUB_OUTPUT" + echo "pkg_ver=${VER}" >> "$GITHUB_OUTPUT" + echo "[version] VER=$VER, TAG=fnos-${VER}" # 复用 docker-publish 已推送的镜像,不在此重新 build/push。 ensure-image: @@ -114,15 +98,14 @@ jobs: - name: Build Docker FPK env: - FPK_NAME_PREFIX: Fnos-octop-vanilla - FPK_ITER: ${{ needs.version.outputs.iter }} + FPK_NAME_PREFIX: Octop-fnos run: bash scripts/build-fpk.sh docker - name: Upload docker fpk artifact uses: actions/upload-artifact@v4 with: name: docker-fpk - path: dist/Fnos-octop-vanilla-*.fpk + path: dist/Octop-fnos-docker-*.fpk if-no-files-found: error native: @@ -219,19 +202,18 @@ jobs: - name: Build native FPK id: build_native env: - FPK_NAME_PREFIX: Fnos-octop-vanilla - FPK_ITER: ${{ needs.version.outputs.iter }} + FPK_NAME_PREFIX: Octop-fnos run: bash scripts/build-fpk.sh native - name: Show native fpk size - run: ls -lh dist/Fnos-octop-vanilla-native-*.fpk + run: ls -lh dist/Octop-fnos-native-*.fpk - name: Upload native fpk artifact id: upload_native uses: actions/upload-artifact@v4 with: name: native-fpk - path: dist/Fnos-octop-vanilla-native-*.fpk + path: dist/Octop-fnos-native-*.fpk if-no-files-found: error - name: Upload native install log (for debugging) @@ -279,11 +261,10 @@ jobs: id: changelog run: | VER="${{ needs.version.outputs.version }}" - ITER="${{ needs.version.outputs.iter }}" IMAGE="${{ needs.version.outputs.image }}" PREV_TAG="" - for t in $(git tag --list "fnos-vanilla-*" --sort=-creatordate); do - [ "$t" = "fnos-vanilla-latest" ] && continue + for t in $(git tag --list "fnos-*" --sort=-creatordate); do + [ "$t" = "fnos-latest" ] && continue if git merge-base --is-ancestor "$t" HEAD 2>/dev/null; then PREV_TAG="$t" break @@ -294,13 +275,13 @@ jobs: echo "## 安装说明" echo "" echo "1. 飞牛应用中心 → 设置 → 手动安装应用,选择对应 .fpk。" - echo "2. Docker 版 **Fnos-octop-vanilla-${VER}-${ITER}.fpk**(约 80KB):飞牛自动从 GHCR 拉取 \`${IMAGE}:latest\`。" - echo "3. 本地版 **Fnos-octop-vanilla-native-${VER}-${ITER}.fpk**(约 200MB):非 Docker,原生运行在飞牛主机;安装时自动关联系统 Python 3.12 开发工具;浏览器、远程桌面等附加组件在 Octop 应用内按需安装。" + echo "2. Docker 版 **Octop-fnos-docker-${VER}.fpk**(约 80KB):飞牛自动从 GHCR 拉取 \`${IMAGE}:latest\`。" + echo "3. 本地版 **Octop-fnos-native-${VER}.fpk**(约 200MB):非 Docker,原生运行在飞牛主机;安装时自动关联系统 Python 3.12 开发工具;浏览器、远程桌面等附加组件在 Octop 应用内按需安装。" echo "" echo "---" - echo "固定版本请从本 Release 附件下载;最新滚动版见 [fnos-vanilla-latest](https://github.com/${{ github.repository }}/releases/tag/fnos-vanilla-latest)。" + echo "按版本号下载对应 .fpk 即可(如 \`Octop-fnos-docker-${VER}.fpk\` / \`Octop-fnos-native-${VER}.fpk\`)。" echo "" - echo "## 本次更新(v${VER}-${ITER})" + echo "## 本次更新(v${VER})" echo "" if [ -n "$PREV_TAG" ]; then git log --oneline --no-merges "$PREV_TAG"..HEAD | head -30 @@ -310,7 +291,7 @@ jobs: } > /tmp/release-body.md cat /tmp/release-body.md - - name: Publish fixed release + - name: Publish versioned release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.tag }} @@ -322,31 +303,5 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Delete rolling release if exists - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - TAG=fnos-vanilla-latest - REL_ID=$(curl -sS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ - https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG \ - | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - if [ -n "$REL_ID" ]; then - curl -sS -X DELETE -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ - https://api.github.com/repos/${{ github.repository }}/releases/$REL_ID || true - fi - curl -sS -X DELETE -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ - https://api.github.com/repos/${{ github.repository }}/git/refs/tags/$TAG || true - - - name: Publish rolling release - uses: softprops/action-gh-release@v2 - with: - tag_name: fnos-vanilla-latest - target_commitish: ${{ github.sha }} - name: Octop (FnOS) latest - body_path: /tmp/release-body.md - files: dist/*.fpk - draft: false - prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e393fc..75919659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ ## [Unreleased] +## [0.9.31] - 2026-09-01 + +### 新增 + +- 聊天流式输出时默认展开思考/工具过程,回答完成后收起(历史记录仍收起) +- 聊天侧栏与 @ 选择仅展示运行中的专家 +- 默认专家与控制台创建的专家一样使用家目录存储;聊天待办按计划顺序展示 +- 工作台浏览器可显式结束本地 Chrome 进程;空闲超时后也会回收(登录态仍保留在磁盘 profile) +- 桌面端右上角窗口控制(最小化 / 最大化 / 关闭进托盘) + +### 修复 + +- 过期 hashed 静态资源返回 404,避免升级后桌面壳加载旧脚本 +- 桌面无边框窗口改为 CSS/JS 拖拽,去掉会挡住右上角按钮的 `InvisibleTitleBarHeight` +- macOS 点击程序坞只恢复主窗口,不再同时弹出托盘设置窗 +- 专家详情技能/子智能体卡片:图标在标题左侧,状态或 id 在标题右侧,描述单独一行 +- 桌面端等待本机服务就绪失败时改为中英文说明(跟随桌面语言设置),不再显示 `/api/health` 英文报错 +- 桌面启动页补齐右上角窗口按钮,并收紧启动/失败状态的展示 +- 桌面托盘设置窗去掉多余空白,右上角只保留关闭;macOS 单击菜单栏图标也会弹出设置 + ## [0.9.30] - 2026-08-31 ### 新增 diff --git a/README.md b/README.md index 5890e029..3b5c2b85 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 5c686478..4e353bc9 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -3,6 +3,8 @@ import { ConfigProvider, theme as antdTheme } from "antd"; import zhCN from "antd/locale/zh_CN"; import enUS from "antd/locale/en_US"; import { useEffect } from "react"; +import DesktopWindowControls from "./components/DesktopWindowControls"; +import { useDesktopChrome } from "./hooks/useDesktopChrome"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { useTranslation } from "react-i18next"; import MainLayout from "./layouts/MainLayout"; @@ -36,6 +38,7 @@ function ThemedApp() { const { isDark, palette, customColor } = useTheme(); const { t, i18n } = useTranslation(); const isMobile = useIsMobile(); + const desktopChrome = useDesktopChrome(); const brandTokens = brandTokensFor(palette, isDark, customColor); // Make antd built-ins (Popconfirm OK/Cancel, Modal default footer, Empty, // Pagination, DatePicker, Table… ) follow the current UI language. @@ -110,6 +113,9 @@ function ThemedApp() { return ( + {desktopChrome ? ( + + ) : null} } /> } /> diff --git a/dashboard/src/api/modules/browser.ts b/dashboard/src/api/modules/browser.ts index f79d3217..dccce0d8 100644 --- a/dashboard/src/api/modules/browser.ts +++ b/dashboard/src/api/modules/browser.ts @@ -139,6 +139,14 @@ export const browserApi = { }, ), + /** Stop the local Chrome process. Login cookies stay in the on-disk profile. */ + shutdown: (profile?: string) => { + const path = profile + ? `/browser/shutdown?profile=${encodeURIComponent(profile)}` + : "/browser/shutdown"; + return request<{ ok: boolean; profile: string }>(path, { method: "POST" }); + }, + // -- Browser stream (WebSocket CDP screencast, ~10 fps) -- /** diff --git a/dashboard/src/api/modules/publishedExperts.ts b/dashboard/src/api/modules/publishedExperts.ts index 33815b86..5f03f650 100644 --- a/dashboard/src/api/modules/publishedExperts.ts +++ b/dashboard/src/api/modules/publishedExperts.ts @@ -44,7 +44,9 @@ export interface InstalledPublishedExpert { agent_id: string; name: string; description: string | null; + state: string; published_expert_id: string; + bootstrap_pending: boolean; } const publishedPath = (expertId: string) => diff --git a/dashboard/src/components/AgentProfileDrawer.module.less b/dashboard/src/components/AgentProfileDrawer.module.less index 4359ebd6..54d73aa0 100644 --- a/dashboard/src/components/AgentProfileDrawer.module.less +++ b/dashboard/src/components/AgentProfileDrawer.module.less @@ -45,15 +45,34 @@ font-family: inherit; } -.skillItemRow { +.entityCard { display: flex; - align-items: center; - gap: 12px; + flex-direction: column; + gap: 2px; width: 100%; min-width: 0; padding: 10px 12px; } +.entityCardHeader { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + + > :last-child { + flex-shrink: 0; + } +} + +.entityCardTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .sectionTitleRow { display: flex; align-items: center; diff --git a/dashboard/src/components/AgentProfileDrawer.test.tsx b/dashboard/src/components/AgentProfileDrawer.test.tsx index ac2881dc..d6bca01c 100644 --- a/dashboard/src/components/AgentProfileDrawer.test.tsx +++ b/dashboard/src/components/AgentProfileDrawer.test.tsx @@ -7,7 +7,23 @@ import type { OctopAgent } from "../context/AgentContext"; vi.mock("../api/request", () => ({ request: vi.fn(async (path: string) => { if (path.endsWith("/skills")) { - return [{ slug: "demo-skill", name: "Demo skill", kind: "workspace" }]; + return [ + { + slug: "demo-skill", + name: "Demo skill", + description: "A demo skill", + kind: "workspace", + emoji: "🎯", + enabled: true, + }, + { + slug: "hub-skill", + name: "Hub skill", + kind: "workspace", + icon_url: "https://cdn.example/skill.png", + enabled: false, + }, + ]; } return { name: "Demo agent", description: "Description" }; }), @@ -19,6 +35,7 @@ vi.mock("../api/modules/subagents", () => ({ slug: "demo-subagent", name: "Demo subagent", description: "Subagent description", + emoji: "🤖", }, ]), })); @@ -101,4 +118,48 @@ describe("AgentProfileDrawer sections", () => { expect(skillHeader).toHaveAttribute("aria-expanded", "true"); expect(subagentHeader).toHaveAttribute("aria-expanded", "true"); }); + + it("shows skill icons the same way the subagent list does", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("🎯")).toBeInTheDocument(); + expect(screen.getByText("🤖")).toBeInTheDocument(); + }); + + const hubIcon = screen.getByRole("img", { name: "Hub skill" }); + expect(hubIcon).toHaveAttribute("src", "https://cdn.example/skill.png"); + }); + + it("puts skill status and subagent id on the title row above the description", async () => { + render( + + + , + ); + + const skillTitle = await screen.findByText("Demo skill"); + const skillStatus = screen.getByText("common.enabled"); + const skillDesc = screen.getByText("A demo skill"); + const skillIcon = screen.getByText("🎯"); + const skillHeader = skillTitle.parentElement; + expect(skillHeader).toContainElement(skillIcon); + expect(skillHeader).toContainElement(skillStatus); + expect(skillHeader).not.toContainElement(skillDesc); + expect(skillHeader?.nextElementSibling).toBe(skillDesc); + + const subTitle = screen.getByText("Demo subagent"); + const subId = screen.getByText("demo-subagent"); + const subDesc = screen.getByText("Subagent description"); + const subIcon = screen.getByText("🤖"); + const subHeader = subTitle.parentElement; + expect(subHeader).toContainElement(subIcon); + expect(subHeader).toContainElement(subId); + expect(subHeader).not.toContainElement(subDesc); + expect(subHeader?.nextElementSibling).toBe(subDesc); + }); }); diff --git a/dashboard/src/components/AgentProfileDrawer.tsx b/dashboard/src/components/AgentProfileDrawer.tsx index 4e4324f1..665d6980 100644 --- a/dashboard/src/components/AgentProfileDrawer.tsx +++ b/dashboard/src/components/AgentProfileDrawer.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { Collapse, Drawer, Spin, Tag } from "antd"; import { ChevronLeft } from "lucide-react"; @@ -15,9 +21,14 @@ import { CopyableResourceId } from "./CopyableResourceId"; import { metaForFile } from "../pages/Experts/components/iconForName"; import { fetchConfigMdFiles } from "../pages/Experts/components/expertFileGroups"; import { useSkillDisplayName } from "../pages/Agent/Skills/skillDisplayNames"; +import { DEFAULT_SKILL_EMOJI } from "../pages/Agent/Skills/skillMarkdown"; import SubagentCatalogDrawer from "../pages/Experts/components/SubagentCatalogDrawer"; import SkillCatalogDrawer from "../pages/Experts/components/SkillCatalogDrawer"; import WorkspaceDrawer from "../pages/Agent/Workspace/components/WorkspaceDrawer"; +import { + resolveSubagentAccent, + subagentAccentIconStyle, +} from "../utils/expertColor"; import expertStyles from "../pages/Experts/index.module.less"; import styles from "./AgentProfileDrawer.module.less"; @@ -32,12 +43,57 @@ interface SkillSummary { description?: string; enabled?: boolean; kind?: "builtin" | "workspace"; + emoji?: string; + icon_url?: string; } function workspaceSkills(skills: SkillSummary[]): SkillSummary[] { return skills.filter((s) => s.kind !== "builtin"); } +const SKILL_ICON_ACCENT = "#8B5CF6"; + +function skillIconStyle(hasImage: boolean): { + color: string; + background: string; +} { + return { + color: SKILL_ICON_ACCENT, + background: hasImage ? "transparent" : `${SKILL_ICON_ACCENT}1a`, + }; +} + +function ProfileEntityCard({ + icon, + title, + trailing, + description, +}: { + icon: ReactNode; + title: ReactNode; + trailing: ReactNode; + description?: ReactNode; +}) { + return ( +

+
+
+ {icon} +
+ {title} +
+ {trailing} +
+ {description ? ( +
{description}
+ ) : null} +
+
+ ); +} + interface AgentProfileDrawerProps { open: boolean; agent: OctopAgent | null; @@ -409,30 +465,40 @@ export default function AgentProfileDrawer({ {t("experts.noSkillFiles")} ) : ( - agentSkills.map((skill) => ( -
-
-
-
- {skillDisplayName(skill)} + agentSkills.map((skill) => { + const iconUrl = skill.icon_url?.trim(); + const label = skillDisplayName(skill); + return ( + + {iconUrl ? ( + {label} + ) : ( + skill.emoji?.trim() || DEFAULT_SKILL_EMOJI + )}
- {skill.description ? ( -
- {skill.description} -
- ) : null} -
- - {skill.enabled === false - ? t("common.disabled") - : t("common.enabled")} - -
-
- )) + } + title={label} + trailing={ + + {skill.enabled === false + ? t("common.disabled") + : t("common.enabled")} + + } + description={skill.description} + /> + ); + }) )} @@ -493,23 +559,20 @@ export default function AgentProfileDrawer({ ) : ( subagents.map((sub) => ( -
-
-
-
- {sub.emoji ? ( - - {sub.emoji} - - ) : null} - {sub.name} -
- {sub.description ? ( -
- {sub.description} -
- ) : null} + + {sub.emoji ?? "🤖"}
+ } + title={sub.name} + trailing={ {sub.slug} -
-
+ } + description={sub.description} + /> )) )} diff --git a/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.module.less b/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.module.less new file mode 100644 index 00000000..be66f27a --- /dev/null +++ b/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.module.less @@ -0,0 +1,104 @@ +.mac, +.windows { + position: fixed; + top: 0; + right: 0; + z-index: 10000; + display: flex; + align-items: center; + height: 32px; + flex-shrink: 0; + user-select: none; + -webkit-app-region: no-drag; +} + +.mac { + gap: 8px; + padding: 0 12px 0 8px; +} + +.light { + width: 12px; + height: 12px; + padding: 0; + border: 0.5px solid transparent; + border-radius: 50%; + cursor: pointer; + position: relative; + appearance: none; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } +} + +.glyph { + opacity: 0; + display: block; + font-size: 9px; + font-weight: 700; + line-height: 1; + color: inherit; + pointer-events: none; + transform: translateY(-0.5px); +} + +.mac:hover .glyph { + opacity: 0.85; +} + +.zoom { + background: #28c840; + border-color: #12a12a; + color: #003a08; +} + +.min { + background: #febc2e; + border-color: #d89e19; + color: #5c3b00; +} + +.close { + background: #ff5f57; + border-color: #e0443e; + color: #4d0000; +} + +.windows { + height: 32px; +} + +.winBtn { + width: 46px; + height: 32px; + padding: 0; + border: none; + background: transparent; + color: var(--fn-text-secondary, #4b5563); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.winBtn:hover { + background: var(--fn-bg-hover, rgba(0, 0, 0, 0.06)); + color: var(--fn-text-primary, #111827); +} + +.winBtn:focus-visible { + outline: 2px solid var(--fn-color-brand, #1677ff); + outline-offset: -2px; +} + +.winClose:hover { + background: #e81123; + color: #fff; +} diff --git a/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.test.tsx b/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.test.tsx new file mode 100644 index 00000000..0b24d15d --- /dev/null +++ b/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DesktopWindowControls from "./DesktopWindowControls"; + +const { emitDesktopWindowAction } = vi.hoisted(() => ({ + emitDesktopWindowAction: vi.fn(), +})); + +vi.mock("../../utils/desktopChrome", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../utils/desktopChrome") + >(); + return { ...actual, emitDesktopWindowAction }; +}); + +describe("DesktopWindowControls", () => { + it("renders traffic lights with close on the far right", async () => { + const user = userEvent.setup(); + render(); + + const buttons = screen.getAllByRole("button"); + expect(buttons).toHaveLength(3); + expect(buttons[0]).toHaveAccessibleName(/maximize/i); + expect(buttons[1]).toHaveAccessibleName(/minimize/i); + expect(buttons[2]).toHaveAccessibleName(/close/i); + + await user.click(buttons[2]); + expect(emitDesktopWindowAction).toHaveBeenCalledWith("close"); + }); + + it("renders Windows caption buttons in min / max / close order", async () => { + const user = userEvent.setup(); + render(); + + const buttons = screen.getAllByRole("button"); + expect(buttons[0]).toHaveAccessibleName(/minimize/i); + expect(buttons[1]).toHaveAccessibleName(/maximize/i); + expect(buttons[2]).toHaveAccessibleName(/close/i); + + await user.click(buttons[0]); + expect(emitDesktopWindowAction).toHaveBeenCalledWith("minimise"); + }); +}); diff --git a/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.tsx b/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.tsx new file mode 100644 index 00000000..bca65d3d --- /dev/null +++ b/dashboard/src/components/DesktopWindowControls/DesktopWindowControls.tsx @@ -0,0 +1,92 @@ +import type { DesktopChromeStyle } from "../../utils/desktopChrome"; +import { emitDesktopWindowAction } from "../../utils/desktopChrome"; +import { Minus, Square, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import styles from "./DesktopWindowControls.module.less"; + +interface DesktopWindowControlsProps { + chrome: DesktopChromeStyle; +} + +export default function DesktopWindowControls({ + chrome, +}: DesktopWindowControlsProps) { + const { t } = useTranslation(); + const minimise = t("desktopChrome.minimize", "Minimize"); + const maximise = t("desktopChrome.maximize", "Maximize"); + const close = t("desktopChrome.close", "Close"); + + if (chrome === "mac") { + return ( +
+ + + +
+ ); + } + + return ( +
+ + + +
+ ); +} diff --git a/dashboard/src/components/DesktopWindowControls/index.ts b/dashboard/src/components/DesktopWindowControls/index.ts new file mode 100644 index 00000000..a1f47528 --- /dev/null +++ b/dashboard/src/components/DesktopWindowControls/index.ts @@ -0,0 +1 @@ +export { default } from "./DesktopWindowControls"; diff --git a/dashboard/src/context/AgentContext.test.ts b/dashboard/src/context/AgentContext.test.ts new file mode 100644 index 00000000..ec489388 --- /dev/null +++ b/dashboard/src/context/AgentContext.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import type { OctopAgent } from "./AgentContext"; +import { + projectChatAgentOption, + selectEnabledExperts, +} from "./AgentContext"; + +function agent( + agent_id: string, + state: OctopAgent["state"], + overrides: Partial = {}, +): OctopAgent { + return { + id: 0, + agent_id, + name: agent_id, + description: null, + persona_mbti: null, + default_model: null, + system_prompt: null, + template_name: null, + state, + last_error: null, + icon: null, + icon_name: null, + icon_url: null, + color: null, + config: {}, + ...overrides, + }; +} + +describe("selectEnabledExperts", () => { + it("keeps only running experts when no resolved id is provided", () => { + const agents = [ + agent("A", "running"), + agent("B", "stopped"), + agent("C", "starting"), + agent("D", "failed"), + agent("E", "stopping"), + ]; + expect(selectEnabledExperts(agents, null).map((a) => a.agent_id)).toEqual([ + "A", + ]); + }); + + it("hides general-assistant and system-doctor style experts once they are stopped", () => { + // User's two scenarios from the bug report. + const agents = [ + agent("G1", "stopped", { template_name: "general-assistant" }), + agent("G2", "running", { template_name: "general-assistant" }), + agent("S1", "stopped", { template_name: "cvm-ai-doctor" }), + agent("S2", "running", { template_name: "cvm-ai-doctor" }), + ]; + const result = selectEnabledExperts(agents, null).map((a) => a.agent_id); + // Both templates follow the same rule: only running ones survive. + expect(result).toEqual(["G2", "S2"]); + }); + + it("hides the focused expert once it is stopped (no pinning)", () => { + // Regression for: stopping the focused expert in the chat page used to + // leave it pinned in the sidebar because of an early pinActive default. + const agents = [ + agent("S1", "stopped", { template_name: "cvm-ai-doctor" }), + agent("G1", "running", { template_name: "general-assistant" }), + ]; + // S1 is the resolvedAgentId (URL is /chat/S1). Without pinActive it must + // disappear from the sidebar just like every other stopped expert. + const sidebar = selectEnabledExperts(agents, "S1").map( + (a) => a.agent_id, + ); + expect(sidebar).toEqual(["G1"]); + // @-picker / minimal-layout path also excludes it. + const pickable = selectEnabledExperts(agents, "S1", { + pinActive: false, + }).map((a) => a.agent_id); + expect(pickable).toEqual(["G1"]); + }); + + it("is a no-op when every expert is running", () => { + const agents = [agent("A", "running"), agent("B", "running")]; + expect(selectEnabledExperts(agents, null)).toEqual(agents); + }); + + it("returns empty when nothing is running and there is no active pin", () => { + const agents = [ + agent("A", "stopped"), + agent("B", "failed"), + ]; + expect(selectEnabledExperts(agents, null)).toEqual([]); + }); + + it("opt-in pinActive=true still keeps the active stopped expert (API stability)", () => { + // Pinning is no longer the default — every production caller passes + // pinActive:false — but the option still exists in case a future + // surface wants to keep the focused expert visible. + const agents = [ + agent("S1", "stopped", { template_name: "cvm-ai-doctor" }), + agent("G1", "running", { template_name: "general-assistant" }), + ]; + const result = selectEnabledExperts(agents, "S1", { pinActive: true }).map( + (a) => a.agent_id, + ); + expect(result).toEqual(["S1", "G1"]); + }); +}); + +describe("projectChatAgentOption", () => { + it("projects the full OctopAgent down to ChatAgentOption shape", () => { + const projected = projectChatAgentOption( + agent("S1", "running", { + icon_name: "cpu", + icon_url: "https://example/icon.png", + color: "#0052D9", + is_shared: 1, + is_owner: 0, + owner_username: "alice", + }), + ); + expect(projected).toEqual({ + agent_id: "S1", + name: "S1", + icon_name: "cpu", + icon_url: "https://example/icon.png", + color: "#0052D9", + is_shared: true, + is_owner: false, + owner_username: "alice", + }); + }); + + it("normalizes undefined owner_username to null", () => { + const projected = projectChatAgentOption(agent("X", "running")); + expect(projected.owner_username).toBeNull(); + expect(projected.is_shared).toBe(false); + expect(projected.is_owner).toBe(false); + }); +}); diff --git a/dashboard/src/context/AgentContext.tsx b/dashboard/src/context/AgentContext.tsx index 3af5ae2b..9bdac26b 100644 --- a/dashboard/src/context/AgentContext.tsx +++ b/dashboard/src/context/AgentContext.tsx @@ -74,8 +74,74 @@ interface AgentContextValue { refresh: (options?: { silent?: boolean; force?: boolean }) => Promise; } +export interface EnabledExpertsOptions { + /** + * When ``true`` (opt-in; the default is ``false``), keep the + * ``resolvedAgentId`` expert in the returned list even if it does not + * match the predicate. Today every caller passes ``false`` so a disabled + * expert disappears from the sidebar / @-picker the moment the user stops + * it, including when it is the currently focused expert. The main panel + * still renders ``AgentNotReadyScreen`` on the same URL so users get a + * clear path to ``/experts`` to restart it. + */ + pinActive?: boolean; +} + const STORAGE_KEY = "octop:active-agent"; +/** + * Pure helper: narrow ``agents`` down to those that are "enabled" for the + * chat surface (running experts). The same predicate is reused by: + * • the chat page left sidebar (``Chat/index.tsx``) + * • the minimal-layout records pane (``MinimalRecordsHost`` — non-/chat + * routes like ``/experts`` show expert folders here) + * • the chat composer's ``@``-mention picker + slash menu + * + * Keep this helper in sync with ``isAgentChatReady`` so disabled experts + * never leak into any chat-side surface. + */ +export function selectEnabledExperts( + agents: OctopAgent[], + resolvedAgentId: string | null | undefined, + options: EnabledExpertsOptions = {}, +): OctopAgent[] { + const { pinActive = false } = options; + const enabled = agents.filter((a) => a.state === "running"); + if (!pinActive || !resolvedAgentId) return enabled; + if (enabled.some((a) => a.agent_id === resolvedAgentId)) return enabled; + const pinnedActive = agents.find((a) => a.agent_id === resolvedAgentId); + if (!pinnedActive) return enabled; + return [pinnedActive, ...enabled]; +} + +/** + * Shared projection used by the chat composer (`ChatInput` / + * ``composerLookups``). Keeps the lightweight ``ChatAgentOption`` shape + * consistent across surfaces — name/icon for the chip, shared badge for + * the picker, owner_username for the current-user indicator. + */ +export function projectChatAgentOption(agent: OctopAgent): { + agent_id: string; + name: string; + icon_name: string | null; + icon_url: string | null; + color: string | null; + is_shared: boolean; + is_owner: boolean; + owner_username: string | null; +} { + return { + agent_id: agent.agent_id, + name: agent.name, + icon_name: agent.icon_name, + icon_url: agent.icon_url, + color: agent.color, + is_shared: Boolean(agent.is_shared), + is_owner: Boolean(agent.is_owner), + owner_username: agent.owner_username ?? null, + }; +} + const defaultValue: AgentContextValue = { agents: [], activeAgentId: null, diff --git a/dashboard/src/hooks/useDesktopChrome.ts b/dashboard/src/hooks/useDesktopChrome.ts new file mode 100644 index 00000000..c71b572f --- /dev/null +++ b/dashboard/src/hooks/useDesktopChrome.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { + applyDesktopChrome, + installDesktopWindowDrag, + isDesktopShell, + resolveDesktopChromeStyle, + type DesktopChromeStyle, +} from "../utils/desktopChrome"; + +/** Activate frameless window chrome after the Wails bridge appears. */ +export function useDesktopChrome(): DesktopChromeStyle | null { + const [style, setStyle] = useState(null); + + useEffect(() => { + let cancelled = false; + const tryApply = () => { + if (cancelled || !isDesktopShell()) return false; + const next = resolveDesktopChromeStyle(); + applyDesktopChrome(next); + installDesktopWindowDrag(); + setStyle(next); + return true; + }; + if (tryApply()) { + return () => { + cancelled = true; + applyDesktopChrome(null); + }; + } + const timer = window.setInterval(() => { + if (tryApply()) window.clearInterval(timer); + }, 250); + const stop = window.setTimeout(() => window.clearInterval(timer), 12_000); + return () => { + cancelled = true; + window.clearInterval(timer); + window.clearTimeout(stop); + applyDesktopChrome(null); + }; + }, []); + + return style; +} diff --git a/dashboard/src/layouts/Header.tsx b/dashboard/src/layouts/Header.tsx index 5d210ac5..eb745bb5 100644 --- a/dashboard/src/layouts/Header.tsx +++ b/dashboard/src/layouts/Header.tsx @@ -29,7 +29,7 @@ export default function Header({ onToggle, isMobile }: HeaderProps) {