diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
new file mode 100644
index 00000000..5187b267
--- /dev/null
+++ b/web/src/utils/format.test.ts
@@ -0,0 +1,32 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { relativeTime } from './format'
+
+describe('relativeTime', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-05-09T12:00:00Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('returns empty string for empty input', () => {
+ expect(relativeTime('')).toBe('')
+ })
+
+ it('returns the original value for invalid timestamps', () => {
+ expect(relativeTime('not-a-time')).toBe('not-a-time')
+ })
+
+ it('returns 刚刚 for times within one minute', () => {
+ expect(relativeTime('2026-05-09T11:59:30Z')).toBe('刚刚')
+ })
+
+ it('returns minute, hour, day, and month suffixes for older timestamps', () => {
+ expect(relativeTime('2026-05-09T11:55:00Z')).toBe('5m')
+ expect(relativeTime('2026-05-09T09:00:00Z')).toBe('3h')
+ expect(relativeTime('2026-05-06T12:00:00Z')).toBe('3d')
+ expect(relativeTime('2026-03-05T12:00:00Z')).toBe('2mo')
+ })
+})
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 0996efb2..011170af 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -10,7 +10,7 @@ export function formatDate(date: Date | string): string {
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
-/** 格式化 Token 数量(带 K/M 后缀) */
+/** 格式化 Token 数量,带 K/M 后缀 */
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
@@ -19,25 +19,31 @@ export function formatTokenCount(n: number): string {
/** 截断文本 */
export function truncate(text: string, maxLen: number): string {
- if (text.length <= maxLen) return text
- return text.slice(0, maxLen - 3) + '...'
+ if (text.length <= maxLen) return text
+ return text.slice(0, maxLen - 3) + '...'
}
-/** 绠€鐭浉瀵规椂闂?*/
+/** 返回简短的相对时间文案 */
export function relativeTime(time: string): string {
- if (!time) return ''
- const d = new Date(time)
- if (isNaN(d.getTime())) return time
- const now = Date.now()
- const diff = now - d.getTime()
- const sec = Math.floor(diff / 1000)
- if (sec < 60) return '鍒氬垰'
- const min = Math.floor(sec / 60)
- if (min < 60) return `${min}m`
- const hr = Math.floor(min / 60)
- if (hr < 24) return `${hr}h`
- const day = Math.floor(hr / 24)
- if (day < 30) return `${day}d`
- const mon = Math.floor(day / 30)
- return `${mon}mo`
+ if (!time) return ''
+
+ const d = new Date(time)
+ if (isNaN(d.getTime())) return time
+
+ const now = Date.now()
+ const diff = now - d.getTime()
+ const sec = Math.floor(diff / 1000)
+ if (sec < 60) return '刚刚'
+
+ const min = Math.floor(sec / 60)
+ if (min < 60) return `${min}m`
+
+ const hr = Math.floor(min / 60)
+ if (hr < 24) return `${hr}h`
+
+ const day = Math.floor(hr / 24)
+ if (day < 30) return `${day}d`
+
+ const mon = Math.floor(day / 30)
+ return `${mon}mo`
}
From df6fb1e5ba09b4ebedbe752764ad79a5f47301a5 Mon Sep 17 00:00:00 2001
From: Yumiue <229866007@qq.com>
Date: Sat, 9 May 2026 10:32:51 +0800
Subject: [PATCH 4/6] =?UTF-8?q?refactor(web):=E7=BE=8E=E5=8C=96=E7=95=8C?=
=?UTF-8?q?=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../panels/FileChangePanel.test.tsx | 124 +++++++-
web/src/components/panels/FileChangePanel.tsx | 282 +++++++++++++-----
2 files changed, 325 insertions(+), 81 deletions(-)
diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx
index d0d95bbb..097e0957 100644
--- a/web/src/components/panels/FileChangePanel.test.tsx
+++ b/web/src/components/panels/FileChangePanel.test.tsx
@@ -313,11 +313,13 @@ describe('FileChangePanel', () => {
expect(screen.getByTestId('change-card-fc-4')).toHaveStyle({ flexShrink: '0' })
})
- it('keeps both fixed tabs visible', () => {
+ it('keeps both fixed tabs visible in the primary switcher and hides secondary chips by default', () => {
render(
)
- expect(screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`)).toBeTruthy()
- expect(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)).toBeTruthy()
+ const primaryTabs = screen.getByTestId('preview-primary-tabs')
+ expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`))
+ expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`))
+ expect(screen.queryByTestId('preview-secondary-tabs')).toBeNull()
})
it('opens a git diff file tab from the fixed git diff view', async () => {
@@ -342,6 +344,122 @@ describe('FileChangePanel', () => {
expect(preview.textContent).toContain('before::after')
})
+ it('renders file preview tabs in the secondary chip row instead of mixing them into the primary switcher', () => {
+ useUIStore.setState({
+ previewTabs: [
+ {
+ id: CHANGES_PREVIEW_TAB_ID,
+ kind: 'changes',
+ title: '鏂囦欢鍙樻洿',
+ closable: false,
+ },
+ {
+ id: GIT_DIFF_PREVIEW_TAB_ID,
+ kind: 'git-diff',
+ title: 'Git Diff',
+ closable: false,
+ },
+ {
+ id: 'file:cmd/neocode/main.go',
+ kind: 'file',
+ title: 'main.go',
+ closable: true,
+ path: 'cmd/neocode/main.go',
+ content: 'package main',
+ loading: false,
+ loaded: true,
+ error: '',
+ truncated: false,
+ is_binary: false,
+ },
+ ],
+ activePreviewTabId: 'file:cmd/neocode/main.go',
+ } as never)
+
+ render(
)
+
+ const primaryTabs = screen.getByTestId('preview-primary-tabs')
+ const secondaryTabs = screen.getByTestId('preview-secondary-tabs')
+ expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`))
+ expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`))
+ expect(primaryTabs).not.toContainElement(screen.getByTestId('preview-tab-file:cmd/neocode/main.go'))
+ expect(secondaryTabs).toContainElement(screen.getByTestId('preview-tab-file:cmd/neocode/main.go'))
+ })
+
+ it('marks the fixed git diff switcher as context-active while a git diff file chip is selected', async () => {
+ render(
)
+
+ fireEvent.click(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`))
+ fireEvent.click(await screen.findByTestId('git-diff-entry-src/main.go'))
+
+ await waitFor(() => {
+ expect(mockGatewayAPI.readGitDiffFile).toHaveBeenCalledWith({ path: 'src/main.go' })
+ })
+
+ expect(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)).toHaveAttribute('data-context-active', 'true')
+ expect(screen.getByTestId('preview-tab-git-diff-file:src/main.go')).toHaveAttribute('aria-selected', 'true')
+ })
+
+ it('falls back to the fixed git diff tab after closing the active git diff file chip', async () => {
+ render(
)
+
+ fireEvent.click(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`))
+ fireEvent.click(await screen.findByTestId('git-diff-entry-src/main.go'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('preview-tab-git-diff-file:src/main.go')).toBeTruthy()
+ })
+
+ fireEvent.click(screen.getByTestId('preview-tab-close-git-diff-file:src/main.go'))
+
+ expect(useUIStore.getState().activePreviewTabId).toBe(GIT_DIFF_PREVIEW_TAB_ID)
+ expect(screen.queryByTestId('preview-tab-git-diff-file:src/main.go')).toBeNull()
+ })
+
+ it('keeps keyboard roving focus across both fixed tabs and file chips', () => {
+ useUIStore.setState({
+ previewTabs: [
+ {
+ id: CHANGES_PREVIEW_TAB_ID,
+ kind: 'changes',
+ title: '鏂囦欢鍙樻洿',
+ closable: false,
+ },
+ {
+ id: GIT_DIFF_PREVIEW_TAB_ID,
+ kind: 'git-diff',
+ title: 'Git Diff',
+ closable: false,
+ },
+ {
+ id: 'file:cmd/neocode/main.go',
+ kind: 'file',
+ title: 'main.go',
+ closable: true,
+ path: 'cmd/neocode/main.go',
+ content: 'package main',
+ loading: false,
+ loaded: true,
+ error: '',
+ truncated: false,
+ is_binary: false,
+ },
+ ],
+ activePreviewTabId: CHANGES_PREVIEW_TAB_ID,
+ } as never)
+
+ render(
)
+
+ const changesTab = screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`)
+ changesTab.focus()
+ fireEvent.keyDown(changesTab, { key: 'ArrowRight' })
+ expect(useUIStore.getState().activePreviewTabId).toBe(GIT_DIFF_PREVIEW_TAB_ID)
+
+ const gitDiffTab = screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)
+ fireEvent.keyDown(gitDiffTab, { key: 'ArrowRight' })
+ expect(useUIStore.getState().activePreviewTabId).toBe('file:cmd/neocode/main.go')
+ })
+
it('opens expanded nested untracked files from the git diff list', async () => {
mockGatewayAPI.listGitDiffFiles.mockResolvedValue({
payload: {
diff --git a/web/src/components/panels/FileChangePanel.tsx b/web/src/components/panels/FileChangePanel.tsx
index 1148b462..b8947a48 100644
--- a/web/src/components/panels/FileChangePanel.tsx
+++ b/web/src/components/panels/FileChangePanel.tsx
@@ -110,6 +110,13 @@ function getPreviewBadge(tab: PreviewTab, fileChanges: FileChange[]) {
return null
}
+function getContextPreviewTabID(activeTab: PreviewTab | undefined) {
+ if (!activeTab) return CHANGES_PREVIEW_TAB_ID
+ if (activeTab.kind === 'file') return CHANGES_PREVIEW_TAB_ID
+ if (activeTab.kind === 'git-diff-file') return GIT_DIFF_PREVIEW_TAB_ID
+ return activeTab.id
+}
+
function formatPreviewMeta(tab: FilePreviewTab) {
const segments: string[] = []
if (typeof tab.size === 'number') {
@@ -484,6 +491,22 @@ function PreviewTabStrip({
onClose: (id: string) => void
}) {
const tabRefs = useRef
>({})
+ const fixedTabs = useMemo(
+ () => tabs.filter((tab) => tab.kind === 'changes' || tab.kind === 'git-diff'),
+ [tabs],
+ )
+ const previewFileTabs = useMemo(
+ () => tabs.filter((tab) => tab.kind === 'file' || tab.kind === 'git-diff-file'),
+ [tabs],
+ )
+ const activeTab = useMemo(
+ () => tabs.find((tab) => tab.id === activeTabId),
+ [activeTabId, tabs],
+ )
+ const contextTabId = useMemo(
+ () => getContextPreviewTabID(activeTab),
+ [activeTab],
+ )
useEffect(() => {
const active = tabRefs.current[activeTabId]
@@ -521,53 +544,107 @@ function PreviewTabStrip({
}
}
- return (
-
- {tabs.map((tab, index) => {
- const badge = getPreviewBadge(tab, fileChanges)
- const active = tab.id === activeTabId
+ const renderTab = (tab: PreviewTab, index: number, appearance: 'switcher' | 'chip') => {
+ const badge = getPreviewBadge(tab, fileChanges)
+ const active = tab.id === activeTabId
+ const contextActive = appearance === 'switcher' && !active && tab.id === contextTabId
+ const title = tab.kind === 'file' || tab.kind === 'git-diff-file' ? tab.path : tab.title
+
+ if (appearance === 'switcher') {
+ return (
+
+ )
+ }
- return (
-
+
+ {tab.closable && (
+
- )
- })}
+
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+
+ {fixedTabs.map((tab) => {
+ const index = tabs.findIndex((entry) => entry.id === tab.id)
+ return renderTab(tab, index, 'switcher')
+ })}
+
+
+ {previewFileTabs.length > 0 && (
+
+ {previewFileTabs.map((tab) => {
+ const index = tabs.findIndex((entry) => entry.id === tab.id)
+ return renderTab(tab, index, 'chip')
+ })}
+
+ )}
)
}
@@ -709,89 +786,138 @@ const styles: Record = {
},
dockHeader: {
display: 'flex',
- alignItems: 'center',
+ alignItems: 'flex-start',
gap: 8,
- padding: '8px 10px 0',
+ padding: '8px 10px 10px',
borderBottom: '1px solid var(--border-primary)',
flexShrink: 0,
},
- tabStrip: {
+ tabStack: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 6,
+ minWidth: 0,
+ flex: 1,
+ },
+ switcherRow: {
display: 'flex',
- alignItems: 'stretch',
+ alignItems: 'center',
+ minWidth: 0,
+ },
+ switcherRail: {
+ display: 'flex',
+ alignItems: 'center',
gap: 2,
+ minWidth: 0,
+ padding: 2,
+ borderRadius: 'var(--radius-md)',
+ background: 'rgba(148, 163, 184, 0.08)',
+ },
+ switcherButton: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: 28,
+ padding: '0 12px',
+ border: 'none',
+ borderRadius: 'var(--radius-sm)',
+ background: 'transparent',
+ color: 'var(--text-secondary)',
+ minWidth: 0,
+ cursor: 'pointer',
+ transition: 'all var(--duration-fast) var(--ease-out)',
+ },
+ switcherButtonActive: {
+ background: 'var(--bg-active)',
+ color: 'var(--text-primary)',
+ boxShadow: 'inset 0 -1px 0 var(--accent)',
+ },
+ switcherButtonContext: {
+ background: 'rgba(148, 163, 184, 0.08)',
+ color: 'var(--text-primary)',
+ boxShadow: 'inset 0 -1px 0 var(--border-primary)',
+ },
+ switcherLabel: {
+ fontFamily: 'var(--font-ui)',
+ fontSize: 12,
+ fontWeight: 500,
+ },
+ chipRow: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 6,
overflowX: 'auto',
- paddingBottom: 8,
- flex: 1,
+ paddingBottom: 2,
},
- tabItem: {
+ chipItem: {
display: 'flex',
alignItems: 'center',
minWidth: 0,
maxWidth: 220,
- borderRadius: '10px 10px 0 0',
- borderTop: '1px solid transparent',
- borderLeft: '1px solid transparent',
- borderRight: '1px solid transparent',
- borderBottom: 'none',
+ height: 26,
+ paddingLeft: 2,
+ borderRadius: 'var(--radius-full)',
+ border: '1px solid transparent',
background: 'rgba(148, 163, 184, 0.08)',
color: 'var(--text-secondary)',
+ flexShrink: 0,
},
- tabItemActive: {
- background: 'var(--bg-primary)',
- borderTopColor: 'var(--border-primary)',
- borderLeftColor: 'var(--border-primary)',
- borderRightColor: 'var(--border-primary)',
+ chipItemActive: {
+ background: 'var(--bg-active)',
+ borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
- boxShadow: '0 -1px 0 rgba(255,255,255,0.03) inset',
},
- tabButton: {
+ chipButton: {
display: 'flex',
alignItems: 'center',
- gap: 6,
+ gap: 8,
minWidth: 0,
maxWidth: 188,
- padding: '8px 10px 7px',
+ height: '100%',
+ padding: '0 10px',
border: 'none',
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
textAlign: 'left',
},
- tabBadge: {
- fontSize: 10,
- fontWeight: 700,
+ chipDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 'var(--radius-full)',
flexShrink: 0,
- fontFamily: 'var(--font-ui)',
},
- tabLabel: {
+ chipLabel: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontFamily: 'var(--font-ui)',
- fontSize: 12,
+ fontSize: 11,
fontWeight: 500,
},
- tabCloseButton: {
+ chipCloseButton: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
- width: 22,
- height: 22,
- marginRight: 6,
+ width: 18,
+ height: 18,
+ marginRight: 4,
border: 'none',
- borderRadius: '999px',
+ borderRadius: 'var(--radius-full)',
background: 'transparent',
color: 'var(--text-tertiary)',
cursor: 'pointer',
flexShrink: 0,
},
+ chipCloseButtonActive: {
+ color: 'var(--text-secondary)',
+ },
closeBtn: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
- marginBottom: 8,
borderRadius: 'var(--radius-sm)',
border: 'none',
background: 'transparent',
From 8f72443500a5673bc0f6563cb519728fbfecf84b Mon Sep 17 00:00:00 2001
From: Yumiue <229866007@qq.com>
Date: Sat, 9 May 2026 10:49:20 +0800
Subject: [PATCH 5/6] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E6=9E=84=E5=BB=BA?=
=?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E7=8E=B0=E5=9C=A8ci=E4=BC=9A?=
=?UTF-8?q?=E5=9C=A8=E5=8F=91=E5=B8=83=E5=89=8D=E8=87=AA=E5=8A=A8=E6=9E=84?=
=?UTF-8?q?=E5=BB=BA=E5=B9=B6=E5=86=85=E5=B5=8Cdist?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/release.yml | 13 +++-
.goreleaser.yaml | 14 +---
README.en.md | 8 +++
README.md | 2 +-
internal/cli/release_config_test.go | 92 +++++++++++++++++++++++++
internal/cli/web_command.go | 63 +++++++++++-------
internal/cli/web_command_test.go | 100 ++++++++++++++++++++++++++++
www/en/guide/install.md | 8 +++
www/guide/install.md | 4 +-
www/guide/web-ui.md | 6 +-
10 files changed, 267 insertions(+), 43 deletions(-)
create mode 100644 internal/cli/release_config_test.go
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1e317d60..f2194183 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,6 +27,17 @@ jobs:
with:
go-version-file: 'go.mod' # 💡 修复点 1:让 Action 自动跟随项目真实的 Go 版本
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Build web dist
+ working-directory: web
+ run: |
+ npm ci
+ npm run build
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
@@ -35,4 +46,4 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 💡 修复点 2:补回本仓库的内置鉴权令牌
- TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
\ No newline at end of file
+ TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 37187dbc..568c5477 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -10,6 +10,8 @@ builds:
- id: neocode
env:
- CGO_ENABLED=0
+ tags:
+ - webembed
ldflags:
- -s -w -X 'neo-code/internal/version.Version={{.Version}}'
goos:
@@ -52,18 +54,6 @@ archives:
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
- files:
- - web/package.json
- - web/package-lock.json
- - web/index.html
- - web/components.json
- - web/tsconfig.json
- - web/tsconfig.app.json
- - web/tsconfig.node.json
- - web/vite.config.ts
- - web/src/**/*
- - web/scripts/**/*
- - web/vite-plugins/**/*
- id: neocode-gateway
ids:
diff --git a/README.en.md b/README.en.md
index 7d563187..fb5af637 100644
--- a/README.en.md
+++ b/README.en.md
@@ -113,6 +113,14 @@ Then start with your workspace:
neocode --workdir /path/to/your/project
```
+To launch the browser-based Web UI:
+
+```bash
+neocode web
+```
+
+Tagged release builds already embed `web/dist` into the `neocode` binary, so the target machine does not need Node.js or npm. When running from source, missing `web/dist` still triggers the local frontend build path.
+
### 4. Common commands
```text
diff --git a/README.md b/README.md
index 2a32173c..7a3d3637 100644
--- a/README.md
+++ b/README.md
@@ -118,7 +118,7 @@ neocode --workdir /path/to/your/project
neocode web
```
-标签发布版会在缺少 `web/dist` 时自动使用发布包内的 `web/` 源码执行 `npm install` 和 `npm run build`。这要求用户机器已安装 Node.js 和 npm;如果你使用源码仓库运行,也保留相同的自动构建行为。
+标签发布版已经将 Web UI 的 `web/dist` 内嵌进 `neocode` 二进制,执行 `neocode web` 时不再要求用户机器安装 Node.js 或 npm。如果你在源码仓库里运行 `go run ./cmd/neocode web`,当本地缺少 `web/dist` 时仍会自动尝试构建前端。
### 4. 常用命令
diff --git a/internal/cli/release_config_test.go b/internal/cli/release_config_test.go
new file mode 100644
index 00000000..90bbb7f6
--- /dev/null
+++ b/internal/cli/release_config_test.go
@@ -0,0 +1,92 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+type goreleaserBuild struct {
+ ID string `yaml:"id"`
+ Tags []string `yaml:"tags"`
+}
+
+type goreleaserConfig struct {
+ Builds []goreleaserBuild `yaml:"builds"`
+}
+
+// repoRootForReleaseConfigTest 解析仓库根目录,供发布配置回归测试读取工作流与 GoReleaser 文件。
+func repoRootForReleaseConfigTest(t *testing.T) string {
+ t.Helper()
+ _, currentFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller(0) failed")
+ }
+ return filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", ".."))
+}
+
+func TestGoReleaserEmbedsWebAssetsOnlyForNeoCode(t *testing.T) {
+ repoRoot := repoRootForReleaseConfigTest(t)
+ raw, err := os.ReadFile(filepath.Join(repoRoot, ".goreleaser.yaml"))
+ if err != nil {
+ t.Fatalf("read .goreleaser.yaml: %v", err)
+ }
+
+ var cfg goreleaserConfig
+ if err := yaml.Unmarshal(raw, &cfg); err != nil {
+ t.Fatalf("unmarshal .goreleaser.yaml: %v", err)
+ }
+
+ builds := make(map[string]goreleaserBuild, len(cfg.Builds))
+ for _, build := range cfg.Builds {
+ builds[build.ID] = build
+ }
+
+ neocodeBuild, ok := builds["neocode"]
+ if !ok {
+ t.Fatal("missing neocode build in .goreleaser.yaml")
+ }
+ if !slicesContains(neocodeBuild.Tags, "webembed") {
+ t.Fatalf("neocode build tags = %v, want webembed", neocodeBuild.Tags)
+ }
+
+ gatewayBuild, ok := builds["neocode-gateway"]
+ if !ok {
+ t.Fatal("missing neocode-gateway build in .goreleaser.yaml")
+ }
+ if slicesContains(gatewayBuild.Tags, "webembed") {
+ t.Fatalf("neocode-gateway build tags = %v, want no webembed", gatewayBuild.Tags)
+ }
+}
+
+func TestReleaseWorkflowBuildsWebDistBeforeGoReleaser(t *testing.T) {
+ repoRoot := repoRootForReleaseConfigTest(t)
+ raw, err := os.ReadFile(filepath.Join(repoRoot, ".github", "workflows", "release.yml"))
+ if err != nil {
+ t.Fatalf("read release workflow: %v", err)
+ }
+ content := string(raw)
+
+ for _, expected := range []string{
+ "actions/setup-node@v4",
+ "npm ci",
+ "npm run build",
+ } {
+ if !strings.Contains(content, expected) {
+ t.Fatalf("release workflow missing %q", expected)
+ }
+ }
+}
+
+func slicesContains(values []string, target string) bool {
+ for _, value := range values {
+ if strings.TrimSpace(value) == target {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/cli/web_command.go b/internal/cli/web_command.go
index 39fcee87..3722da13 100644
--- a/internal/cli/web_command.go
+++ b/internal/cli/web_command.go
@@ -25,6 +25,12 @@ var (
webCommandStartGatewayServer = startGatewayServer
webCommandBuildFrontend = buildFrontend
webCommandLookPath = exec.LookPath
+ webCommandEmbeddedAssets = func() (fs.FS, bool) {
+ if !webassets.IsAvailable() {
+ return nil, false
+ }
+ return webassets.FS, true
+ }
)
type webCommandOptions struct {
@@ -57,7 +63,7 @@ func newWebCommand() *cobra.Command {
cmd.Flags().StringVar(&options.LogLevel, "log-level", "info", "gateway log level: debug|info|warn|error")
cmd.Flags().StringVar(&options.StaticDir, "static-dir", "", "frontend static files directory override")
cmd.Flags().BoolVar(&options.OpenBrowser, "open-browser", true, "open browser automatically")
- cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip frontend build (error if dist/ missing)")
+ cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip local frontend build (still works with embedded assets)")
cmd.Flags().StringVar(&options.TokenFile, "token-file", "", "gateway auth token file path")
return cmd
@@ -66,6 +72,7 @@ func newWebCommand() *cobra.Command {
// runWebCommand 执行 web 子命令:解析前端目录 → 构建前端(可选) → 启动 Gateway → 打开浏览器。
func runWebCommand(ctx context.Context, options webCommandOptions) error {
logger := log.New(os.Stderr, "neocode-web: ", log.LstdFlags)
+ embeddedAssets, embeddedAssetsAvailable := webCommandEmbeddedAssets()
// 如果未指定 workdir,默认使用当前工作目录
if strings.TrimSpace(options.Workdir) == "" {
@@ -76,47 +83,55 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error {
// 1. 解析前端静态文件目录
staticDir, err := resolveWebStaticDir(options.StaticDir)
- needBuild := err != nil
-
- // 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时)
- if err == nil && options.StaticDir == "" {
- webDir := findWebSourceDir()
- if webDir != "" && isStaleFrontendBuild(webDir) {
- logger.Println("frontend source is newer than build output, rebuilding...")
- needBuild = true
+ switch {
+ case options.StaticDir != "":
+ if err != nil {
+ return fmt.Errorf("invalid --static-dir: %w", err)
}
- }
-
- if needBuild {
+ case err != nil && embeddedAssetsAvailable:
+ logger.Println("frontend dist not found, falling back to embedded assets")
+ staticDir = ""
+ case err != nil:
if options.SkipBuild {
- return fmt.Errorf("frontend needs rebuild and --skip-build is set")
+ return fmt.Errorf("frontend assets missing and --skip-build is set")
}
webDir := findWebSourceDir()
if webDir == "" {
- if err != nil {
- return fmt.Errorf(
- "frontend assets unavailable: %w; release packages must include the web/ source directory, or source builds must run from the project root or use --static-dir",
- err,
- )
- }
return fmt.Errorf(
- "web source directory not found; release packages must include web/, or source builds must run from project root or set --static-dir",
+ "frontend assets unavailable: %w; source builds must run from the project root, provide --static-dir, or use a release binary with embedded web assets",
+ err,
)
}
if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil {
- return fmt.Errorf("frontend build failed on this machine after detecting bundled web source: %w", buildErr)
+ return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr)
}
- // 构建后重新解析
staticDir, err = resolveWebStaticDir(options.StaticDir)
if err != nil {
return fmt.Errorf("frontend dist not found after build: %w", err)
}
+ default:
+ // 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时)
+ webDir := findWebSourceDir()
+ if webDir != "" && isStaleFrontendBuild(webDir) {
+ logger.Println("frontend source is newer than build output, rebuilding...")
+ if options.SkipBuild {
+ return fmt.Errorf("frontend needs rebuild and --skip-build is set")
+ }
+ if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil {
+ return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr)
+ }
+ staticDir, err = resolveWebStaticDir(options.StaticDir)
+ if err != nil {
+ return fmt.Errorf("frontend dist not found after build: %w", err)
+ }
+ }
}
+
// 2. 确定静态文件来源:外部目录优先,找不到时回退到嵌入资源
var staticFileFS fs.FS
if staticDir == "" {
- if webassets.IsAvailable() {
- staticFileFS = webassets.FS
+ if embeddedAssetsAvailable {
+ staticFileFS = embeddedAssets
logger.Println("serving web UI from embedded assets")
} else {
logger.Println("warning: no web UI assets found (external dist missing and embedded assets not compiled)")
diff --git a/internal/cli/web_command_test.go b/internal/cli/web_command_test.go
index 0f2cde78..0c0d2335 100644
--- a/internal/cli/web_command_test.go
+++ b/internal/cli/web_command_test.go
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "testing/fstest"
)
// writeWebCommandTestFile 写入 web 命令测试所需的最小文件内容,避免各测试重复拼装目录。
@@ -76,6 +77,18 @@ func stubWebCommandHooks(
})
}
+// stubWebCommandEmbeddedAssets 替换内嵌前端资源探测逻辑,便于覆盖发布版回退分支。
+func stubWebCommandEmbeddedAssets(t *testing.T, assets fs.FS, available bool) {
+ t.Helper()
+ original := webCommandEmbeddedAssets
+ webCommandEmbeddedAssets = func() (fs.FS, bool) {
+ return assets, available
+ }
+ t.Cleanup(func() {
+ webCommandEmbeddedAssets = original
+ })
+}
+
func TestFindWebSourceDirUsesCurrentWorkdir(t *testing.T) {
tempDir := t.TempDir()
chdirForWebCommandTest(t, tempDir)
@@ -186,3 +199,90 @@ func TestRunWebCommandBuildsFrontendWhenDistMissing(t *testing.T) {
t.Fatalf("startGatewayServer staticDir = %q, want %q", capturedStaticDir, wantStaticDir)
}
}
+
+func TestRunWebCommandUsesEmbeddedAssetsWhenDistMissing(t *testing.T) {
+ tempDir := t.TempDir()
+ chdirForWebCommandTest(t, tempDir)
+ writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}")
+
+ buildCalled := false
+ var capturedStaticDir string
+ var capturedStaticFS fs.FS
+ sentinelErr := errors.New("stop after start")
+ stubWebCommandEmbeddedAssets(t, fstest.MapFS{
+ "index.html": &fstest.MapFile{Data: []byte("")},
+ }, true)
+ stubWebCommandHooks(
+ t,
+ func(_ context.Context, _ gatewayCommandOptions, staticDir string, staticFS fs.FS, _ func(string)) error {
+ capturedStaticDir = staticDir
+ capturedStaticFS = staticFS
+ return sentinelErr
+ },
+ func(_ string, _ *log.Logger) error {
+ buildCalled = true
+ return nil
+ },
+ nil,
+ )
+
+ err := runWebCommand(context.Background(), webCommandOptions{
+ HTTPAddress: "127.0.0.1:8080",
+ LogLevel: "info",
+ OpenBrowser: false,
+ Workdir: tempDir,
+ })
+ if !errors.Is(err, sentinelErr) {
+ t.Fatalf("runWebCommand() error = %v, want sentinel error %v", err, sentinelErr)
+ }
+ if buildCalled {
+ t.Fatal("runWebCommand() unexpectedly invoked frontend build when embedded assets were available")
+ }
+ if capturedStaticDir != "" {
+ t.Fatalf("startGatewayServer staticDir = %q, want empty string", capturedStaticDir)
+ }
+ if capturedStaticFS == nil {
+ t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS")
+ }
+}
+
+func TestRunWebCommandSkipBuildStillUsesEmbeddedAssets(t *testing.T) {
+ tempDir := t.TempDir()
+ chdirForWebCommandTest(t, tempDir)
+
+ buildCalled := false
+ var capturedStaticFS fs.FS
+ sentinelErr := errors.New("stop after start")
+ stubWebCommandEmbeddedAssets(t, fstest.MapFS{
+ "index.html": &fstest.MapFile{Data: []byte("")},
+ }, true)
+ stubWebCommandHooks(
+ t,
+ func(_ context.Context, _ gatewayCommandOptions, _ string, staticFS fs.FS, _ func(string)) error {
+ capturedStaticFS = staticFS
+ return sentinelErr
+ },
+ func(_ string, _ *log.Logger) error {
+ buildCalled = true
+ return nil
+ },
+ nil,
+ )
+
+ err := runWebCommand(context.Background(), webCommandOptions{
+ HTTPAddress: "127.0.0.1:8080",
+ LogLevel: "info",
+ OpenBrowser: false,
+ SkipBuild: true,
+ Workdir: tempDir,
+ })
+ if !errors.Is(err, sentinelErr) {
+ t.Fatalf("runWebCommand() error = %v, want sentinel error %v", err, sentinelErr)
+ }
+ if buildCalled {
+ t.Fatal("runWebCommand() unexpectedly invoked frontend build when --skip-build used with embedded assets")
+ }
+ if capturedStaticFS == nil {
+ t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS")
+ }
+}
diff --git a/www/en/guide/install.md b/www/en/guide/install.md
index e0fec326..68edbcde 100644
--- a/www/en/guide/install.md
+++ b/www/en/guide/install.md
@@ -63,6 +63,14 @@ neocode --workdir /path/to/your/project
Type natural language in the input box to chat. Type `/` to see local control commands.
+NeoCode also provides a browser-based Web UI:
+
+```bash
+neocode web
+```
+
+Tagged release builds start the Web UI from assets already embedded in the `neocode` binary, so the target machine does not need Node.js or npm. When running from source, a missing `web/dist` still triggers a local frontend build.
+
## 5. First conversation
Try:
diff --git a/www/guide/install.md b/www/guide/install.md
index 6c4f1563..252b516e 100644
--- a/www/guide/install.md
+++ b/www/guide/install.md
@@ -73,7 +73,7 @@ neocode --workdir /path/to/your/project
neocode web
```
-标签发布版执行 `neocode web` 时,如果本地还没有 `web/dist`,会自动使用发布包内的 `web/` 源码执行 `npm install` 和 `npm run build`,然后启动 Web UI。该流程要求当前机器已安装 Node.js 和 npm。
+标签发布版执行 `neocode web` 时,会直接使用二进制内嵌的 Web UI 资源启动,不要求当前机器安装 Node.js 或 npm。
## 5. 第一次对话
@@ -118,7 +118,7 @@ go build ./...
go run ./cmd/neocode
```
-如果你希望从源码仓库直接验证 Web UI,也可以运行 `go run ./cmd/neocode web`。当 `web/dist` 缺失时,命令会自动尝试构建前端;若构建机没有 Node.js/npm,会直接报出依赖缺失提示。
+如果你希望从源码仓库直接验证 Web UI,也可以运行 `go run ./cmd/neocode web`。当 `web/dist` 缺失时,命令会自动尝试构建前端;若构建机没有 Node.js/npm,会直接报出依赖缺失提示。也就是说,只有源码运行场景仍依赖本地前端构建链路。
如果你只想稳定使用,优先使用一键安装方式。源码构建更适合阅读代码、调试功能或参与开发。
diff --git a/www/guide/web-ui.md b/www/guide/web-ui.md
index d4d83ec1..98bfc86d 100644
--- a/www/guide/web-ui.md
+++ b/www/guide/web-ui.md
@@ -14,7 +14,7 @@ neocode web
```
启动后浏览器会自动打开 `http://127.0.0.1:8080`。如果端口被占用,会自动尝试 8081 ~ 8090。
-如果当前目录或发布包内存在 `web/` 源码但还没有 `web/dist`,命令会自动执行 `npm install` 和 `npm run build`。标签发布版使用该能力时,用户机器必须预先安装 Node.js 和 npm。
+标签发布版会直接使用二进制内嵌的 Web UI 资源启动,不要求用户机器安装 Node.js 或 npm。只有在源码仓库运行、且本地缺少 `web/dist` 时,命令才会自动执行 `npm install` 和 `npm run build`。
### 常用参数
@@ -22,7 +22,7 @@ neocode web
|------|--------|------|
| `--http-listen` | `127.0.0.1:8080` | 监听地址(仅允许回环地址) |
| `--open-browser` | `true` | 启动后自动打开浏览器 |
-| `--skip-build` | `false` | 跳过前端构建(dist/ 缺失时会报错;仅在你已准备好预构建资源时使用) |
+| `--skip-build` | `false` | 跳过本地前端构建(若二进制已内嵌资源仍可启动;主要用于源码模式或自备 dist 的场景) |
| `--static-dir` | — | 指定前端静态文件目录 |
| `--log-level` | `info` | 日志级别:debug / info / warn / error |
| `--token-file` | — | 自定义认证 token 文件路径 |
@@ -57,7 +57,7 @@ Web UI 支持两种运行模式,根据启动方式自动选择:
### 浏览器模式
通过 `neocode web` 或直接在浏览器中访问 Gateway 地址时使用。首次连接需要输入 Gateway URL 和 token,配置保存在 sessionStorage 中。
-如果你使用标签发布版,首次运行 `neocode web` 可能先看到前端依赖安装与构建日志;构建完成后会继续启动 Web UI。若机器缺少 Node.js/npm,命令会直接提示安装依赖。
+如果你使用标签发布版,`neocode web` 会直接启动内嵌的 Web UI。若你使用源码仓库运行并且本地没有 `web/dist`,命令才会先执行前端依赖安装与构建;此时如果机器缺少 Node.js/npm,会直接提示安装依赖。
## 核心功能
From 3cec0e03f794530f42744357e0b9167ea695293b Mon Sep 17 00:00:00 2001
From: Yumiue <229866007@qq.com>
Date: Sat, 9 May 2026 11:51:17 +0800
Subject: [PATCH 6/6] =?UTF-8?q?fix:=E4=BF=AE=E6=8A=A4tui=E5=90=AF=E5=8A=A8?=
=?UTF-8?q?=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/cli/gateway_runtime_bridge.go | 9 ++++--
internal/cli/gateway_runtime_bridge_test.go | 35 +++++++++++++++++++++
2 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go
index 37050465..b75c122d 100644
--- a/internal/cli/gateway_runtime_bridge.go
+++ b/internal/cli/gateway_runtime_bridge.go
@@ -2109,10 +2109,13 @@ func (b *gatewayRuntimePortBridge) resolveEffectiveProviderModel(
}
session, err := b.loadStoredSession(ctx, sessionID)
if err != nil {
- return "", "", err
+ if !isRuntimeNotFoundError(err) {
+ return "", "", err
+ }
+ } else {
+ sessionProviderID = strings.TrimSpace(session.Provider)
+ sessionModelID = strings.TrimSpace(session.Model)
}
- sessionProviderID = strings.TrimSpace(session.Provider)
- sessionModelID = strings.TrimSpace(session.Model)
}
cfg := b.currentConfig()
diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go
index ad11ba13..08484bd2 100644
--- a/internal/cli/gateway_runtime_bridge_test.go
+++ b/internal/cli/gateway_runtime_bridge_test.go
@@ -2630,6 +2630,41 @@ func TestGatewayRuntimePortBridgeListModelsUsesSessionProvider(t *testing.T) {
}
}
+func TestGatewayRuntimePortBridgeListModelsSessionNotFoundFallsBackToGlobal(t *testing.T) {
+ store := &bridgeSessionStoreWithLoader{
+ bridgeSessionStoreStub: bridgeSessionStoreStub{},
+ loadErr: agentsession.ErrSessionNotFound,
+ }
+ cfgMgr := &configManagerStub{
+ cfg: config.Config{
+ SelectedProvider: "gemini",
+ CurrentModel: "gemini-2.5-pro",
+ },
+ }
+ ps := &providerSelectionStub{
+ listOptions: []configstate.ProviderOption{
+ {ID: "openai", Models: []providertypes.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}},
+ {ID: "gemini", Models: []providertypes.ModelDescriptor{{ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"}}},
+ },
+ }
+ bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, store, cfgMgr, ps)
+ defer bridge.Close()
+
+ models, err := bridge.ListModels(context.Background(), gateway.ListModelsInput{
+ SubjectID: testBridgeSubjectID,
+ SessionID: "session-startup-probe-1",
+ })
+ if err != nil {
+ t.Fatalf("ListModels() error = %v", err)
+ }
+ if len(models) != 1 {
+ t.Fatalf("models len = %d, want 1", len(models))
+ }
+ if models[0].Provider != "gemini" || models[0].ID != "gemini-2.5-pro" {
+ t.Fatalf("models = %+v, want gemini/gemini-2.5-pro only", models)
+ }
+}
+
func TestGatewayRuntimePortBridgeGetSessionModelFallsBackToEffectiveSelection(t *testing.T) {
store := &bridgeSessionStoreWithLoader{
bridgeSessionStoreStub: bridgeSessionStoreStub{},