feat(desktop): project topic tabs and context inspector (#2925)

* feat(desktop): add project tabs and context inspector

* feat(desktop): refine project workspace UI

* fix 打开一致加载

* 顶部统一logo+左侧边栏+统一菜单

* 底部最低宽度

* update much

* update code

* update code

* feat(desktop): refine workspace layout and message actions

* fix(desktop): scope file refs to active tab root

* test(desktop): stabilize capabilities config dirs

* fix(desktop): protect sessions open in other tabs

* fix(desktop): resume scoped history in matching tab

* chore(desktop): drop committed scratch artifacts; ignore tmp/ and .codex/

The project-tabs branch carried 30 throwaway screenshots, a mock HTML,
and a codex env file into the tree because tmp/ and .codex/ were never
ignored. Remove them and add the ignore rules so scratch output stays
out of future commits.

* test(desktop): isolate AppData per test so the suite passes on Windows

The new project/topic/workspace tests only overrode HOME and
XDG_CONFIG_HOME, but desktopConfigDir() and config.MemoryUserDir()
resolve through os.UserConfigDir(), which reads %AppData% on Windows and
ignores those vars. With AppData pointed at one shared TestMain dir, the
project/topic/workspace registries leaked across tests: full-package
runs failed (project tree len 2 want 1, trash mixing other tests'
sessions, Plugins[0] reading a neighbor's MCP entry) while each test
passed alone.

Route every test through isolateDesktopUserDirs, which already sets
HOME, USERPROFILE, XDG_CONFIG_HOME, and AppData to a per-test temp dir.
go test ./... (and -race) now pass on Windows.

---------

Co-authored-by: reasonix <[email protected]>
This commit is contained in:
SivanColaandreasonix authored and GitHub committed 2026-06-04 19:41:16 -07:00
1 parent 55915d9941
commit b87a878cad
83 files changed
+15225 -3426

No files matched your search

+4
View File
@@ -54,3 +54,7 @@ node_modules/
Thumbs.db
.idea/
.vscode/
# Scratch / agent working dirs (never committed)
/tmp/
/.codex/
+1114 -522
View File
File diff suppressed because it is too large. Load diff
+30 -9
View File
@@ -46,27 +46,48 @@ func waitForFile(t *testing.T, path, want string) {
t.Fatalf("session file %q never contained %q", path, want)
}
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
t.Helper()
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "test_tab",
Ctrl: ctrl,
Scope: "global",
WorkspaceRoot: "",
Ready: true,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
a := &App{
tabs: map[string]*WorkspaceTab{"test_tab": tab},
activeTabID: "test_tab",
}
tab.sink.app = a
return a, tab
}
// TestTurnDonePersistsSession proves a completed turn is written to disk without
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a := &App{ctrl: controllerWithContent(t, path)}
a.sink = &eventSink{app: a}
_ = path
a, tab := appWithTab(t, path)
a.sink.Emit(event.Event{Kind: event.TurnDone})
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
_ = a
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
// per-token event storm doesn't thrash the disk.
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a := &App{ctrl: controllerWithContent(t, path)}
a.sink = &eventSink{app: a}
a, tab := appWithTab(t, path)
_ = a
a.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -78,11 +99,11 @@ func TestNonTurnDoneDoesNotPersist(t *testing.T) {
// single-flight loop neither panics nor drops the final write.
func TestScheduleSnapshotCoalesces(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a := &App{ctrl: controllerWithContent(t, path)}
a.sink = &eventSink{app: a}
a, tab := appWithTab(t, path)
_ = a
for i := 0; i < 64; i++ {
go a.scheduleSnapshot()
go tab.sink.Emit(event.Event{Kind: event.TurnDone})
}
waitForFile(t, path, "acknowledged")
+462 -123
View File
@@ -6,14 +6,53 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/plugin"
"reasonix/internal/provider"
)
// setTestCtrl creates a minimal workspace tab (if needed) and sets its
// controller, so tests don't depend on the old App.ctrl field.
func (a *App) setTestCtrl(ctrl *control.Controller, model string) {
if len(a.tabs) == 0 {
tab := &WorkspaceTab{
ID: "test",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabs = map[string]*WorkspaceTab{"test": tab}
a.activeTabID = "test"
}
tab := a.tabs["test"]
tab.Ctrl = ctrl
tab.model = model
}
func isolateDesktopUserDirs(t *testing.T) string {
t.Helper()
home := t.TempDir()
xdg := filepath.Join(home, ".config")
appData := filepath.Join(home, "AppData")
for _, dir := range []string{xdg, appData} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("AppData", appData)
return home
}
func TestCommandsIncludesEffortNotThinking(t *testing.T) {
app := NewApp()
cmds := app.Commands()
@@ -26,8 +65,7 @@ func TestCommandsIncludesEffortNotThinking(t *testing.T) {
}
func TestEffortDefaultsBeforeStartup(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
got := NewApp().Effort()
if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") {
@@ -35,61 +73,22 @@ func TestEffortDefaultsBeforeStartup(t *testing.T) {
}
}
func TestSettingsFallbackKeepsNestedDefaultsOnLoadFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte("default_model = [\n"), 0o644); err != nil {
t.Fatal(err)
}
got := NewApp().Settings()
if got.Providers == nil || got.ProviderKinds == nil {
t.Fatalf("Settings fallback providers/kinds should be non-nil: %+v", got)
}
if got.Permissions.Mode != "ask" || got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil {
t.Fatalf("Settings fallback permissions should be safe defaults: %+v", got.Permissions)
}
if got.Sandbox.Bash != "enforce" || got.Sandbox.AllowWrite == nil {
t.Fatalf("Settings fallback sandbox should be safe defaults: %+v", got.Sandbox)
}
}
func TestRebuildKeepsOldControllerOnBuildFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
t.Chdir(dir)
func TestEmitReadyInvokesReadyHook(t *testing.T) {
app := NewApp()
app.ctx = context.Background()
app.model = "deepseek-flash/deepseek-v4-flash"
old := control.New(control.Options{Label: "old-controller"})
app.ctrl = old
defer func() {
if app.ctrl != nil {
app.ctrl.Close()
}
}()
var calls int32
app.readyHook = func() {
atomic.AddInt32(&calls, 1)
}
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte("default_model = [\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := app.rebuild(); err == nil {
t.Fatal("rebuild should fail for malformed config")
}
if app.ctrl != old {
t.Fatalf("rebuild failure replaced controller: got %p want old %p", app.ctrl, old)
}
if app.startupErr == "" {
t.Fatal("rebuild failure should surface startupErr")
app.emitReady(nil)
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("ready hook calls = %d, want 1", got)
}
}
func TestSetEffortPersistsAndAutoClears(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SetEffort("max"); err != nil {
@@ -113,28 +112,133 @@ func TestSetEffortPersistsAndAutoClears(t *testing.T) {
}
}
func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
language = "zh"
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopLanguage("en"); err != nil {
t.Fatalf("set desktop language: %v", err)
}
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
t.Fatalf("set desktop close behavior: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
got := NewApp().Settings()
if got.DesktopLanguage != "en" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" {
t.Fatalf("desktop settings = lang:%q theme:%q style:%q close:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior)
}
}
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
default_model = "legacy-provider/legacy-model"
[desktop]
language = "zh"
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got := app.Settings()
if got.ConfigPath != config.UserConfigPath() {
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
}
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" {
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
}
if err := app.SetDesktopLanguage("en"); err != nil {
t.Fatalf("SetDesktopLanguage: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" {
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q theme:%q style:%q close:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior())
}
}
func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopLanguage("en"); err != nil {
t.Fatalf("set desktop language: %v", err)
}
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
t.Fatalf("migrate desktop preferences: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if got.DesktopLanguage() != "en" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
t.Fatalf("desktop prefs after migration = lang:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopTheme(), got.DesktopThemeStyle())
}
}
func TestSetEffortRebuildsController(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
app := NewApp()
app.ctx = context.Background()
app.model = "deepseek-flash/deepseek-v4-flash"
app.readyHook = func() {}
old := control.New(control.Options{Label: "old-controller"})
app.ctrl = old
app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
defer func() {
if app.ctrl != nil {
app.ctrl.Close()
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.SetEffort("max"); err != nil {
t.Fatalf("SetEffort(max): %v", err)
}
if app.ctrl == nil {
if c := app.activeCtrl(); c == nil {
t.Fatal("SetEffort should leave a rebuilt controller")
}
if app.ctrl == old {
if c := app.activeCtrl(); c == old {
t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
}
if got := app.Effort().Current; got != "max" {
@@ -143,13 +247,12 @@ func TestSetEffortRebuildsController(t *testing.T) {
}
func TestSetEffortRejectsRunningTurn(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
app := NewApp()
app.ctrl = control.New(control.Options{Runner: runner})
app.ctrl.Submit("work")
app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
app.activeCtrl().Submit("work")
<-runner.started
err := app.SetEffort("max")
@@ -158,7 +261,7 @@ func TestSetEffortRejectsRunningTurn(t *testing.T) {
}
close(runner.release)
waitNotRunning(t, app.ctrl)
waitNotRunning(t, app.activeCtrl())
}
func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
@@ -191,9 +294,51 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
}
}
func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
t.Fatal(err)
}
projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := NewApp()
tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.activeTabID = tab.ID
listed := app.ListDir("")
if !hasDirEntry(listed, "frontend") {
t.Fatalf("ListDir should list active project root, got %+v", listed)
}
if hasDirEntry(listed, "launch-only.txt") {
t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
}
found := app.SearchFileRefs("runtime.js")
if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
}
preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
if preview.Err != "" || preview.Body != "right workspace" {
t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
}
}
func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -205,8 +350,12 @@ func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
}
app := NewApp()
app.ctrl = control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.DeleteSession(filepath.Base(path)); err != errActiveSession {
t.Fatalf("DeleteSession(active basename) error = %v, want errActiveSession", err)
@@ -216,9 +365,164 @@ func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
}
}
func TestDeleteSessionRejectsInactiveOpenTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
activePath := filepath.Join(dir, "active.jsonl")
inactivePath := filepath.Join(dir, "inactive.jsonl")
otherPath := filepath.Join(dir, "other.jsonl")
for _, path := range []string{activePath, inactivePath, otherPath} {
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session %s: %v", path, err)
}
}
activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
defer activeCtrl.Close()
defer inactiveCtrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{
"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
},
tabOrder: []string{"active", "inactive"},
activeTabID: "active",
}
if err := app.DeleteSession(filepath.Base(inactivePath)); err != errActiveSession {
t.Fatalf("DeleteSession(inactive open basename) error = %v, want errActiveSession", err)
}
if _, err := os.Stat(inactivePath); err != nil {
t.Fatalf("inactive open session should remain: %v", err)
}
sessions := app.ListSessions()
current := map[string]bool{}
for _, s := range sessions {
current[filepath.Base(s.Path)] = s.Current
}
if !current[filepath.Base(activePath)] || !current[filepath.Base(inactivePath)] {
t.Fatalf("ListSessions should mark active and inactive open sessions current, got %#v", current)
}
if current[filepath.Base(otherPath)] {
t.Fatalf("ListSessions marked unopened session current, got %#v", current)
}
}
type appendingDesktopRunner struct {
session *agent.Session
started chan string
}
func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
r.started <- input
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
return nil
}
func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
isolateDesktopUserDirs(t)
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte("[codegraph]\nenabled = false\n"), 0o644); err != nil {
t.Fatalf("write workspace config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
path := agent.NewSessionPath(dir, "test")
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
runner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}
ctrl := control.New(control.Options{
Runner: runner,
Executor: exec,
Sink: event.Discard,
SessionDir: dir,
SessionPath: path,
Label: "test",
WorkspaceRoot: workspace,
})
app := NewApp()
app.setTestCtrl(ctrl, "deepseek/test")
app.tabs["test"].Scope = "project"
app.tabs["test"].WorkspaceRoot = workspace
app.tabs["test"].TopicID = "topic_source"
app.tabs["test"].TopicTitle = "Source topic"
defer ctrl.Close()
ctrl.Submit("first")
<-runner.started
waitNotRunning(t, ctrl)
ctrl.Submit("second")
<-runner.started
waitNotRunning(t, ctrl)
if got := len(ctrl.History()); got != 5 {
t.Fatalf("source history len before fork = %d, want 5", got)
}
meta, err := app.Fork(1)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if !meta.Active || meta.ID == "" || meta.ID == "test" {
t.Fatalf("fork meta = %+v, want a new active tab", meta)
}
if got := app.activeTabID; got != meta.ID {
t.Fatalf("active tab = %q, want fork tab %q", got, meta.ID)
}
if got := ctrl.SessionPath(); got != path {
t.Fatalf("source controller session path = %q, want %q", got, path)
}
if got := len(ctrl.History()); got != 5 {
t.Fatalf("source history len after fork = %d, want 5", got)
}
if got, want := meta.TopicTitle, "Source topic · 分叉"; got != want {
t.Fatalf("fork topic title = %q, want %q", got, want)
}
var forkPath string
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read session dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
candidate := filepath.Join(dir, entry.Name())
if candidate == path {
continue
}
m, ok, err := agent.LoadBranchMeta(candidate)
if err != nil {
t.Fatalf("load fork meta: %v", err)
}
if ok && m.TopicID == meta.TopicID {
forkPath = candidate
if m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {
t.Fatalf("fork branch meta = %+v, want parent %q turn 1 index 3", m, agent.BranchID(path))
}
if m.Scope != "project" || m.WorkspaceRoot != workspace || m.TopicTitle != "Source topic · 分叉" {
t.Fatalf("fork topic meta = %+v", m)
}
}
}
if forkPath == "" {
t.Fatalf("fork session with topic %q not found in %s", meta.TopicID, dir)
}
}
func TestCapabilitiesShowsLazyMCPAsDeferredNotDisabled(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -234,8 +538,12 @@ args = ["-y", "@playwright/mcp"]
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -250,16 +558,13 @@ args = ["-y", "@playwright/mcp"]
}
func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("AppData", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -283,8 +588,7 @@ func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
}
func TestCapabilitiesMarksDeferredRemoteMCPAuthPossible(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -301,8 +605,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -317,8 +621,7 @@ tier = "lazy"
}
func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -336,8 +639,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -352,8 +655,7 @@ tier = "lazy"
}
func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -372,8 +674,8 @@ tier = "lazy"
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
app := NewApp()
app.ctrl = control.New(control.Options{Host: host})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: host}), "")
defer app.activeCtrl().Close()
view := app.Capabilities()
for _, s := range view.Servers {
@@ -388,8 +690,7 @@ tier = "lazy"
}
func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -410,8 +711,8 @@ tier = "lazy"
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
app := NewApp()
app.ctrl = control.New(control.Options{Host: host})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: host}), "")
defer app.activeCtrl().Close()
if err := app.ClearMCPServerAuthentication("figma"); err != nil {
t.Fatalf("ClearMCPServerAuthentication: %v", err)
@@ -452,8 +753,7 @@ tier = "lazy"
}
func TestUpdateMCPServerKeepsLazyMCPDeferred(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -470,8 +770,12 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.UpdateMCPServer("playwright", MCPServerInput{
Name: "playwright",
@@ -492,6 +796,18 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("env TOKEN = %q, want preserved env", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
if !ok {
t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("user plugin after migration = %+v", userPlugin)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
@@ -508,8 +824,7 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
}
func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -525,8 +840,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.UpdateMCPServer("broken", MCPServerInput{
Name: "broken",
@@ -546,8 +861,8 @@ tier = "lazy"
if got := cfg.Plugins[0].Tier; got != "background" {
t.Fatalf("updated tier = %q, want background", got)
}
if !mcpFailed(app.ctrl, "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.ctrl.Host().Failures())
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
}
view := app.Capabilities()
for _, s := range view.Servers {
@@ -565,8 +880,7 @@ tier = "lazy"
}
func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -582,8 +896,12 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
if err := app.SetMCPServerTier("broken", "background"); err != nil {
t.Fatalf("SetMCPServerTier should persist tier even when immediate connect fails: %v", err)
@@ -595,8 +913,20 @@ tier = "lazy"
if got := cfg.Plugins[0].Tier; got != "background" {
t.Fatalf("saved tier = %q, want background", got)
}
if !mcpFailed(app.ctrl, "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.ctrl.Host().Failures())
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
if !ok {
t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Tier != "background" {
t.Fatalf("user plugin tier = %q, want background", userPlugin.Tier)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
}
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
}
view := app.Capabilities()
for _, s := range view.Servers {
@@ -631,8 +961,8 @@ auto_install = true
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerTier("codegraph", "background"); err != nil {
t.Fatalf("SetMCPServerTier(codegraph): %v", err)
@@ -647,8 +977,15 @@ auto_install = true
if got := cfg.Codegraph.Tier; got != "background" {
t.Fatalf("codegraph tier = %q, want background", got)
}
if !mcpFailed(app.ctrl, "codegraph") {
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.ctrl.Host().Failures())
userCfg := config.LoadForEdit(config.UserConfigPath())
if !userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = false, want true after selecting a startup tier")
}
if got := userCfg.Codegraph.Tier; got != "background" {
t.Fatalf("user codegraph tier = %q, want background", got)
}
if !mcpFailed(app.activeCtrl(), "codegraph") {
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
}
view := app.Capabilities()
for _, s := range view.Servers {
@@ -666,8 +1003,7 @@ auto_install = true
}
func TestSetMCPServerEnabledPersistsCodegraphOff(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -679,8 +1015,8 @@ tier = "lazy"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if err := app.SetMCPServerEnabled("codegraph", false); err != nil {
t.Fatalf("SetMCPServerEnabled(codegraph,false): %v", err)
@@ -692,6 +1028,10 @@ tier = "lazy"
if cfg.Codegraph.Enabled {
t.Fatal("codegraph enabled = true, want false after disabling")
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = true, want false after disabling")
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "codegraph" {
@@ -705,8 +1045,7 @@ tier = "lazy"
}
func TestCapabilitiesKeepsFailedMCPConfiguredTierAfterRestart(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
@@ -722,9 +1061,9 @@ tier = "eager"
}
app := NewApp()
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
defer app.ctrl.Close()
recordMCPFailure(app.ctrl, config.PluginEntry{
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
recordMCPFailure(app.activeCtrl(), config.PluginEntry{
Name: "broken",
Command: "reasonix-missing-mcp-binary",
Tier: "eager",
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"encoding/json"
"reflect"
"testing"
)
func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
cases := []struct {
name string
got any
}{
{"Checkpoints", app.Checkpoints()},
{"ListSessions", app.ListSessions()},
{"ListTrashedSessions", app.ListTrashedSessions()},
{"ListWorkspaces", app.ListWorkspaces()},
{"History", app.History()},
{"Jobs", app.Jobs()},
{"Commands", app.Commands()},
{"Models", app.Models()},
{"ListDir", app.ListDir("__missing__")},
{"ListTabs", app.ListTabs()},
{"ListProjectTree", app.ListProjectTree()},
}
for _, tc := range cases {
assertNonNilSliceJSON(t, tc.name, tc.got)
}
if got := app.SlashArgs("/skill "); got.Items == nil {
t.Fatal("SlashArgs().Items is nil; frontend expects []")
}
if got := app.WorkspaceChanges(); got.Files == nil {
t.Fatal("WorkspaceChanges().Files is nil; frontend expects []")
}
if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
}
if got := app.Settings(); got.Providers == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil {
t.Fatalf("Settings() contains nil array fields: %+v", got)
}
}
func assertNonNilSliceJSON(t *testing.T, name string, got any) {
t.Helper()
v := reflect.ValueOf(got)
if v.Kind() != reflect.Slice {
t.Fatalf("%s returned %T, want slice", name, got)
}
if v.IsNil() {
t.Fatalf("%s returned nil slice; frontend expects []", name)
}
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("%s JSON marshal: %v", name, err)
}
if string(raw) == "null" {
t.Fatalf("%s JSON encoded as null; frontend expects []", name)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,119 @@
import { useEffect, useLayoutEffect, useState } from "react";
import type { CSSProperties, ReactNode, RefObject } from "react";
import { createPortal } from "react-dom";
type PopoverPosition = {
left: number;
top: number;
};
const EDGE_GAP = 8;
const DEFAULT_OFFSET = 8;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function samePosition(a: PopoverPosition | null, b: PopoverPosition): boolean {
return !!a && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5;
}
function calculatePosition(
anchor: DOMRect,
menu: DOMRect,
align: "start" | "end",
offset: number,
placement: "auto" | "bottom",
): PopoverPosition {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const preferredTop = anchor.top - menu.height - offset;
const fallbackTop = anchor.bottom + offset;
const top = placement === "bottom"
? Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP))
: preferredTop >= EDGE_GAP
? preferredTop
: Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP));
const rawLeft = align === "end" ? anchor.right - menu.width : anchor.left;
const left = clamp(rawLeft, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - menu.width - EDGE_GAP));
return { left, top: clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) };
}
export function AnchoredPopover({
open,
anchorRef,
onClose,
className,
children,
align = "start",
offset = DEFAULT_OFFSET,
placement = "auto",
style,
}: {
open: boolean;
anchorRef: RefObject<HTMLElement>;
onClose: () => void;
className: string;
children: ReactNode;
align?: "start" | "end";
offset?: number;
placement?: "auto" | "bottom";
style?: CSSProperties;
}) {
const [position, setPosition] = useState<PopoverPosition | null>(null);
useLayoutEffect(() => {
if (!open) {
setPosition(null);
return;
}
const anchor = anchorRef.current?.getBoundingClientRect();
const menu = document.querySelector<HTMLElement>("[data-anchored-popover='active']")?.getBoundingClientRect();
if (!anchor || !menu) return;
const next = calculatePosition(anchor, menu, align, offset, placement);
setPosition((current) => (samePosition(current, next) ? current : next));
});
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
const closeOnViewportChange = () => onClose();
window.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", closeOnViewportChange);
window.addEventListener("scroll", closeOnViewportChange, true);
return () => {
window.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", closeOnViewportChange);
window.removeEventListener("scroll", closeOnViewportChange, true);
};
}, [onClose, open]);
if (!open) return null;
return createPortal(
<>
<div className="anchored-popover__backdrop" onMouseDown={onClose} />
<div
data-anchored-popover="active"
className={`anchored-popover ${className}`}
style={{
...style,
left: position?.left ?? -9999,
top: position?.top ?? -9999,
visibility: position ? "visible" : "hidden",
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
>
{children}
</div>
</>,
document.body,
);
}
@@ -22,6 +22,7 @@ export function ApprovalModal({
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const isPlanApproval = approval.tool === "exit_plan_mode";
const subject = approval.subject.trim();
const subjectSummary = subject.split("\n").find((line) => line.trim())?.trim() ?? "";
const choosePlanAction = (key: string) => {
if (key === "1") setRevisionOpen((open) => !open);
@@ -123,10 +124,11 @@ export function ApprovalModal({
barRef={cardRef}
titleId="tool-approval-title"
title={t("approval.toolPending")}
actionsWrap
meta={
<>
<span className="tool__name">{approval.tool}</span>
{subject && <span className="prompt-shelf__subject"> · {subject}</span>}
{subjectSummary && <span className="prompt-shelf__subject"> · {subjectSummary}</span>}
</>
}
actions={
+14 -19
View File
@@ -150,6 +150,7 @@ export function AskCard({
barRef={shelfRef}
titleId="ask-shelf-title"
title={t("ask.title")}
actionsWrap
badges={
<>
{q.header && <PromptBadge>{q.header}</PromptBadge>}
@@ -159,6 +160,11 @@ export function AskCard({
meta={q.prompt}
actions={
<>
{active > 0 && (
<button className="prompt-action prompt-action--quiet" onClick={goBack}>
<span className="prompt-action__label">{t("ask.back")}</span>
</button>
)}
{q.options.map((o, index) => {
const on = (sel[q.id] ?? []).includes(o.label);
return (
@@ -171,12 +177,20 @@ export function AskCard({
/>
);
})}
{q.multi && (
<button className="prompt-action prompt-action--selected" onClick={() => finishOrAdvance()} disabled={!currentAnswered}>
<span className="prompt-action__label">{isLast ? t("common.submit") : t("ask.next")}</span>
</button>
)}
<PromptDetailToggle
open={detailsOpen}
label={t("ask.details")}
openLabel={t("ask.hideDetails")}
onClick={() => setDetailsOpen((open) => !open)}
/>
<button className="prompt-action prompt-action--quiet" onClick={onDismiss}>
<span className="prompt-action__label">{t("ask.justChat")}</span>
</button>
</>
}
crumbs={
@@ -190,25 +204,6 @@ export function AskCard({
</div>
)
}
quickActions={
!detailsOpen && (
<div className="ask-shelf__quick-actions">
{active > 0 && (
<button className="btn" onClick={goBack}>
{t("ask.back")}
</button>
)}
<button className="btn" onClick={onDismiss}>
{t("ask.justChat")}
</button>
{q.multi && (
<button className="btn btn--primary" onClick={() => finishOrAdvance()} disabled={!currentAnswered}>
{isLast ? t("common.submit") : t("ask.next")}
</button>
)}
</div>
)
}
>
{detailsOpen && (
<>
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
@@ -21,7 +23,6 @@ export function CapabilitiesPanel({
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [confirming, setConfirming] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [tab, setTab] = useState<CapTab>("servers");
const [skillQuery, setSkillQuery] = useState("");
@@ -31,7 +32,7 @@ export function CapabilitiesPanel({
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
const reload = useCallback(async () => {
setView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] })));
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
}, []);
useEffect(() => {
void reload();
@@ -84,7 +85,7 @@ export function CapabilitiesPanel({
}, [filteredSkills, skillQuery, t, view]);
const serverGroups = useMemo(() => {
const servers = view?.servers ?? [];
const servers = sortServersForDisplay(view?.servers ?? []);
return {
failed: servers.filter((s) => s.status === "failed"),
active: servers.filter((s) => s.status !== "failed"),
@@ -181,10 +182,9 @@ export function CapabilitiesPanel({
expanded={expandedErrors}
onToggle={toggleError}
onRetry={(name) => void mutate(() => app.RetryMCPServer(name))}
confirming={confirming}
onConfirm={setConfirming}
onCancelConfirm={() => setConfirming(null)}
onRemove={(name) => mutate(() => app.RemoveMCPServer(name)).then(() => setConfirming(null))}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))}
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
busy={busy}
/>
)}
@@ -196,17 +196,14 @@ export function CapabilitiesPanel({
servers={serverGroups.active}
expanded={expandedServers}
expandedTools={expandedServerTools}
confirming={confirming}
editing={editing}
onConfirm={setConfirming}
onCancelConfirm={() => setConfirming(null)}
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
onEdit={(name) => {
setEditing(name);
setConfirming(null);
}}
onCancelEdit={() => setEditing(null)}
onRemove={(name) => mutate(() => app.RemoveMCPServer(name)).then(() => setConfirming(null))}
onRetry={(name) => void mutate(() => app.RetryMCPServer(name))}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))}
onUpdate={(name, input) =>
@@ -258,8 +255,10 @@ export function CapabilitiesPanel({
<SkillRow
key={sk.name}
skill={sk}
busy={busy}
expanded={expandedSkills.has(sk.name)}
onToggle={() => toggleSkill(sk.name)}
onToggleEnabled={(enabled) => void mutate(() => app.SetSkillEnabled(sk.name, enabled))}
/>
))}
</div>
@@ -272,6 +271,39 @@ export function CapabilitiesPanel({
);
}
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
return {
servers: sortServersForDisplay(
asArray(view?.servers).map((server) => ({
...server,
args: asArray(server.args),
envKeys: asArray(server.envKeys),
toolList: asArray(server.toolList),
})),
),
skills: asArray(view?.skills),
skillRoots: asArray(view?.skillRoots).map((root) => ({
...root,
skillItems: asArray(root.skillItems),
})),
};
}
function sortServersForDisplay(servers: ServerView[]): ServerView[] {
return [...servers].sort((a, b) => {
const priority = serverDisplayPriority(a) - serverDisplayPriority(b);
if (priority !== 0) return priority;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}
function serverDisplayPriority(server: ServerView): number {
if (server.status === "failed" || server.authStatus === "required") return 0;
if (server.builtIn) return 1;
if (server.status !== "disabled") return 2;
return 3;
}
function skillListSummary(skills: SkillView[], filtered: SkillView[], searching: boolean, t: ReturnType<typeof useT>): string {
if (searching) {
return t("caps.skillsSummaryMatches", { matched: filtered.length, total: skills.length });
@@ -324,7 +356,6 @@ function SkillSources({
const t = useT();
const [expanded, setExpanded] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [confirmingRoot, setConfirmingRoot] = useState<string | null>(null);
const [expandedRootSkills, setExpandedRootSkills] = useState<Set<string>>(() => new Set());
const [fullRootSkills, setFullRootSkills] = useState<Set<string>>(() => new Set());
const primaryRoots = roots.filter(isPrimarySkillRoot);
@@ -335,11 +366,6 @@ function SkillSources({
const active = summaryRoots.filter((root) => root.skills > 0).length;
const missing = summaryRoots.filter((root) => root.status === "missing").length;
const empty = summaryRoots.filter((root) => root.status === "ok" && root.skills === 0).length;
useEffect(() => {
if (!confirmingRoot) return;
const stillRemovable = roots.some((root) => root.dir === confirmingRoot && root.scope === "custom" && root.configured);
if (!stillRemovable) setConfirmingRoot(null);
}, [confirmingRoot, roots]);
const toggleRootSkills = (key: string) => {
setExpandedRootSkills((prev) => {
const next = new Set(prev);
@@ -421,43 +447,29 @@ function SkillSources({
</div>
{(canShowRootSkills || canRemoveRoot) && (
<div className="cap-source-actions">
{confirmingRoot === root.dir && canRemoveRoot ? (
<>
<>
{canShowRootSkills && (
<button
className="btn btn--small"
disabled={busy}
onClick={() => {
onRemove(root.dir);
setConfirmingRoot(null);
}}
type="button"
aria-expanded={rootSkillsExpanded}
onClick={() => toggleRootSkills(key)}
>
{t("caps.skillRootConfirmRemove")}
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
</button>
<button className="btn btn--small" disabled={busy} onClick={() => setConfirmingRoot(null)}>
{t("common.cancel")}
</button>
<div className="cap-confirm-hint">{t("caps.skillRootRemoveHint")}</div>
</>
) : (
<>
{canShowRootSkills && (
<button
className="btn btn--small"
disabled={busy}
type="button"
aria-expanded={rootSkillsExpanded}
onClick={() => toggleRootSkills(key)}
>
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
</button>
)}
{canRemoveRoot && (
<button className="btn btn--small" disabled={busy} onClick={() => setConfirmingRoot(root.dir)}>
{t("caps.skillRootRemove")}
</button>
<InlineConfirmButton
label={t("caps.skillRootRemove")}
confirmLabel={t("caps.skillRootConfirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={() => onRemove(root.dir)}
/>
)}
</>
)}
</div>
)}
{rootSkillsExpanded && rootSkills.length > 0 && (
@@ -577,14 +589,12 @@ function ServerGroup({
expanded,
expandedTools,
busy,
confirming,
editing,
onConfirm,
onCancelConfirm,
onEdit,
onCancelEdit,
onRemove,
onRetry,
onConfirmClearAuth,
onToggle,
onSetTier,
onUpdate,
@@ -595,14 +605,12 @@ function ServerGroup({
expanded: Set<string>;
expandedTools: Set<string>;
busy: boolean;
confirming: string | null;
editing: string | null;
onConfirm: (name: string) => void;
onCancelConfirm: () => void;
onEdit: (name: string) => void;
onCancelEdit: () => void;
onRemove: (name: string) => void;
onRetry: (name: string) => void;
onConfirmClearAuth: (name: string) => void;
onToggle: (name: string, on: boolean) => void;
onSetTier: (name: string, tier: string) => void;
onUpdate: (name: string, input: MCPServerInput) => void;
@@ -619,14 +627,12 @@ function ServerGroup({
expanded={expanded.has(s.name)}
toolsExpanded={expandedTools.has(s.name)}
busy={busy}
confirming={confirming === s.name}
editing={editing === s.name}
onConfirm={() => onConfirm(s.name)}
onCancelConfirm={onCancelConfirm}
onEdit={() => onEdit(s.name)}
onCancelEdit={onCancelEdit}
onRemove={() => onRemove(s.name)}
onRetry={() => onRetry(s.name)}
onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
onToggle={(on) => onToggle(s.name, on)}
onSetTier={(tier) => onSetTier(s.name, tier)}
onUpdate={(input) => onUpdate(s.name, input)}
@@ -642,22 +648,20 @@ function FailedServersNotice({
servers,
expanded,
busy,
confirming,
onToggle,
onRetry,
onConfirmClearAuth,
onSetTier,
onConfirm,
onCancelConfirm,
onRemove,
}: {
servers: ServerView[];
expanded: Set<string>;
busy: boolean;
confirming: string | null;
onToggle: (name: string) => void;
onRetry: (name: string) => void;
onConfirmClearAuth: (name: string) => void;
onSetTier: (name: string, tier: string) => void;
onConfirm: (name: string) => void;
onCancelConfirm: () => void;
onRemove: (name: string) => void;
}) {
const t = useT();
return (
@@ -673,10 +677,10 @@ function FailedServersNotice({
const open = expanded.has(s.name);
const error = s.error || t("caps.failed");
const actionLabel = serverActionLabel(s, t);
const authURL = authURLForServer(s);
const canConfigure = s.configured;
const handlePrimaryAction = () => {
if (isAuthFailure(error) && authURL) {
openExternal(authURL);
if (shouldOpenAuth(s)) {
openExternal((s.authUrl || "").trim());
return;
}
onRetry(s.name);
@@ -687,36 +691,41 @@ function FailedServersNotice({
<span className="cap-dot cap-dot--failed" />
<div className="cap-failure__text">
<div className="cap-failure__name">{s.name}</div>
<div className="cap-failure__summary">{summarizeServerError(error, t)}</div>
<div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error, t)}</div>
</div>
</div>
<div className="cap-failure__actions">
{confirming === s.name ? (
<>
<button className="btn btn--small" disabled={busy} onClick={() => onRemove(s.name)}>
{t("caps.confirmRemove")}
</button>
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
{t("common.cancel")}
</button>
<div className="cap-confirm-hint">{t("caps.removeHint")}</div>
</>
) : (
<>
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
{actionLabel}
</button>
<button className="btn btn--small" onClick={() => onToggle(s.name)} aria-expanded={open}>
{open ? t("common.collapse") : t("caps.showLog")}
</button>
{!s.builtIn && (
<button className="btn btn--small" disabled={busy} onClick={() => onConfirm(s.name)}>
{t("caps.remove")}
</button>
)}
</>
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
{actionLabel}
</button>
{canClearAuth(s) && (
<InlineConfirmButton
label={t("caps.clearAuth")}
confirmLabel={t("caps.confirmClearAuth")}
cancelLabel={t("common.cancel")}
disabled={busy}
onConfirm={() => onConfirmClearAuth(s.name)}
/>
)}
<button className="btn btn--small" onClick={() => onToggle(s.name)} aria-expanded={open}>
{open ? t("common.collapse") : t("caps.showLog")}
</button>
{!s.builtIn && (
<InlineConfirmButton
label={t("caps.remove")}
confirmLabel={t("caps.confirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={() => onConfirm(s.name)}
/>
)}
</div>
{canConfigure && (
<div className="cap-failure__mode">
<AutoConnectControls tier={s.tier || "lazy"} busy={busy} onTierChange={(tier) => onSetTier(s.name, tier)} />
</div>
)}
{open && (
<div className="cap-failure__logbox">
<div className="cap-failure__logbar">
@@ -741,14 +750,12 @@ function ServerRow({
expanded,
toolsExpanded,
busy,
confirming,
editing,
onConfirm,
onCancelConfirm,
onEdit,
onCancelEdit,
onRemove,
onRetry,
onConfirmClearAuth,
onToggle,
onSetTier,
onUpdate,
@@ -759,14 +766,12 @@ function ServerRow({
expanded: boolean;
toolsExpanded: boolean;
busy: boolean;
confirming: boolean;
editing: boolean;
onConfirm: () => void;
onCancelConfirm: () => void;
onEdit: () => void;
onCancelEdit: () => void;
onRemove: () => void;
onRetry: () => void;
onConfirmClearAuth: () => void;
onToggle: (on: boolean) => void;
onSetTier: (tier: string) => void;
onUpdate: (input: MCPServerInput) => void;
@@ -776,7 +781,7 @@ function ServerRow({
const t = useT();
const actionLabel = serverActionLabel(s, t);
const tools = s.toolList ?? [];
const sub =
let sub =
s.status === "failed"
? s.error || t("caps.failed")
: s.status === "initializing"
@@ -788,7 +793,17 @@ function ServerRow({
? t("caps.disabledAutoStart")
: t("caps.disabled")
: t("caps.counts", { tools: s.tools, prompts: s.prompts, resources: s.resources });
if (s.authStatus === "possible" && s.status !== "failed") {
sub = `${sub} · ${t("caps.authPossibleShort")}`;
}
const enabled = s.status === "connected" || s.status === "deferred" || s.status === "initializing";
const handlePrimaryAction = () => {
if (shouldOpenAuth(s)) {
openExternal((s.authUrl || "").trim());
return;
}
onRetry();
};
return (
<div className={`cap-server-entry${s.status === "disabled" ? " cap-server-entry--disabled" : ""}`}>
<Tooltip label={s.error} disabled={!s.error} fill block>
@@ -807,49 +822,29 @@ function ServerRow({
<div className="cap-row__head">
<span className="cap-row__name">{s.name}</span>
<span className="cap-row__transport">{s.transport}</span>
{s.builtIn && <span className="cap-row__builtin">{t("caps.builtIn")}</span>}
</div>
<div className="cap-row__sub">{sub}</div>
</div>
<div className="cap-row__actions">
{confirming ? (
<>
<button className="btn btn--small" disabled={busy} onClick={onRemove}>
{t("caps.confirmRemove")}
</button>
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
{t("common.cancel")}
</button>
</>
{s.status === "failed" ? (
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
{actionLabel}
</button>
) : s.status === "initializing" ? (
<span className="cap-row__pending">{t("caps.initializingShort")}</span>
) : (
<>
{s.status === "failed" ? (
<button className="btn btn--small" disabled={busy} onClick={onRetry}>
{actionLabel}
</button>
) : s.status === "initializing" ? (
<span className="cap-row__pending">{t("caps.initializingShort")}</span>
) : s.builtIn ? (
s.status === "disabled" ? (
<button className="btn btn--small" disabled={busy} onClick={() => onToggle(true)}>
{t("caps.enable")}
</button>
) : (
<span className="cap-row__builtin">{t("caps.builtIn")}</span>
)
) : (
<Tooltip label={enabled ? t("caps.disable") : t("caps.enable")}>
<label className="cap-switch">
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => onToggle(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
)}
</>
<Tooltip label={enabled ? t("caps.disable") : t("caps.enable")}>
<label className="cap-switch">
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => onToggle(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
)}
</div>
</div>
@@ -859,11 +854,9 @@ function ServerRow({
s={s}
tools={tools}
busy={busy}
confirming={confirming}
onConfirm={onConfirm}
onCancelConfirm={onCancelConfirm}
onRemove={onRemove}
onConnectNow={onRetry}
onConfirmClearAuth={onConfirmClearAuth}
onSetTier={onSetTier}
toolsExpanded={toolsExpanded}
editing={editing}
@@ -881,11 +874,9 @@ function ServerDetails({
s,
tools,
busy,
confirming,
onConfirm,
onCancelConfirm,
onRemove,
onConnectNow,
onConfirmClearAuth,
onSetTier,
toolsExpanded,
editing,
@@ -897,11 +888,9 @@ function ServerDetails({
s: ServerView;
tools: ServerView["toolList"];
busy: boolean;
confirming: boolean;
onConfirm: () => void;
onCancelConfirm: () => void;
onRemove: () => void;
onConnectNow: () => void;
onConfirmClearAuth: () => void;
onSetTier: (tier: string) => void;
toolsExpanded: boolean;
editing: boolean;
@@ -912,10 +901,13 @@ function ServerDetails({
}) {
const t = useT();
const command = serverCommand(s);
const canConfigure = s.configured && !s.builtIn;
const canConnectNow = !s.builtIn && (s.status === "deferred" || s.status === "disabled");
const canConfigure = s.configured;
const canEditConfig = s.configured && !s.builtIn;
const canConnectNow = s.status === "deferred" || s.status === "disabled";
const canShowTools = (s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0;
if (editing && canConfigure) {
const showClearAuth = canClearAuth(s);
const authLabel = serverAuthLabel(s, t);
if (editing && canEditConfig) {
return (
<div className="cap-server-details">
<EditServerForm s={s} busy={busy} onCancel={onCancelEdit} onSave={onUpdate} />
@@ -933,6 +925,12 @@ function ServerDetails({
<span className="cap-detail__label">{t("caps.transport")}</span>
<span className="cap-detail__value">{s.transport}</span>
</div>
{authLabel && (
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.auth")}</span>
<span className="cap-detail__value">{authLabel}</span>
</div>
)}
{canConfigure && (
<AutoConnectControls tier={s.tier || "lazy"} busy={busy} onTierChange={onSetTier} />
)}
@@ -960,27 +958,30 @@ function ServerDetails({
{toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
</button>
)}
{canConfigure && (
<button className="btn btn--small" disabled={busy} onClick={onEdit}>
{t("caps.editConfig")}
</button>
{showClearAuth && (
<InlineConfirmButton
label={t("caps.clearAuth")}
confirmLabel={t("caps.confirmClearAuth")}
cancelLabel={t("common.cancel")}
disabled={busy}
onConfirm={onConfirmClearAuth}
/>
)}
{canConfigure &&
(confirming ? (
<>
<button className="btn btn--small" disabled={busy} onClick={onRemove}>
{t("caps.confirmRemove")}
</button>
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
{t("common.cancel")}
</button>
<div className="cap-confirm-hint">{t("caps.removeHint")}</div>
</>
) : (
<button className="btn btn--small" disabled={busy} onClick={onConfirm}>
{t("caps.remove")}
{canEditConfig && (
<>
<button className="btn btn--small" disabled={busy} onClick={onEdit}>
{t("caps.editConfig")}
</button>
))}
<InlineConfirmButton
label={t("caps.remove")}
confirmLabel={t("caps.confirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={onConfirm}
/>
</>
)}
</div>
{toolsExpanded && (
tools && tools.length > 0 ? (
@@ -1165,6 +1166,7 @@ function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
case "disabled":
return s.configured && !s.autoStart ? t("caps.disabledAutoStart") : t("caps.disabled");
case "failed":
if (s.authStatus === "required") return t("caps.authRequired");
return t("caps.failed");
default:
return s.status;
@@ -1186,21 +1188,9 @@ function summarizeServerError(error: string, t: ReturnType<typeof useT>): string
return summary.length > 180 ? `${summary.slice(0, 176).trim()}…` : summary;
}
function isAuthFailure(error: string): boolean {
const err = error.toLowerCase();
return err.includes("401") || err.includes("403") || err.includes("unauthorized") || err.includes("forbidden") || err.includes("invalid token") || err.includes("login required");
}
function authURLForServer(s: ServerView): string {
if (!isAuthFailure(s.error || "")) return "";
if (s.transport !== "http" && s.transport !== "sse") return "";
const url = (s.url || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
const err = (s.error || "").toLowerCase();
if (isAuthFailure(err)) return t("caps.reauthorize");
if (shouldOpenAuth(s)) return t("caps.reauthorize");
if (
err.includes("command not found") ||
err.includes("executable file not found") ||
@@ -1212,38 +1202,76 @@ function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
return t("caps.retry");
}
function serverAuthLabel(s: ServerView, t: ReturnType<typeof useT>): string {
if (s.authStatus === "required") return t("caps.authRequired");
if (s.authStatus === "possible") return t("caps.authPossible");
return "";
}
function shouldOpenAuth(s: ServerView): boolean {
const url = (s.authUrl || "").trim();
return s.authStatus === "required" && /^https?:\/\//i.test(url);
}
function canClearAuth(s: ServerView): boolean {
if (!s.configured || s.builtIn) return false;
return Boolean(s.authConfigured || s.authStatus === "required" || s.authStatus === "possible" || isRemoteTransport(s.transport));
}
function isRemoteTransport(transport?: string): boolean {
const value = (transport || "").trim().toLowerCase();
return value === "http" || value === "streamable-http" || value === "sse";
}
function SkillRow({
skill,
busy,
expanded,
onToggle,
onToggleEnabled,
}: {
skill: SkillView;
busy: boolean;
expanded: boolean;
onToggle: () => void;
onToggleEnabled: (enabled: boolean) => void;
}) {
const t = useT();
const summary = summarizeSkillDescription(skill.description);
const canExpand = summary !== skill.description;
return (
<button
className={`cap-skill-card${expanded ? " cap-skill-card--expanded" : ""}${canExpand ? " cap-skill-card--expandable" : ""}`}
type="button"
onClick={onToggle}
aria-expanded={expanded}
<div
className={`cap-skill-card${expanded ? " cap-skill-card--expanded" : ""}${canExpand ? " cap-skill-card--expandable" : ""}${!skill.enabled ? " cap-skill-card--disabled" : ""}`}
>
<div className="cap-skill-card__head">
<span className="cap-skill-card__icon">/</span>
<span className="cap-skill-card__main">
<span className="cap-skill-card__command">{skill.name}</span>
<span className="cap-skill-card__badges">
<span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
{skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
<div className="cap-skill-card__top">
<button className="cap-skill-card__toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
<span className="cap-skill-card__head">
<span className="cap-skill-card__icon">/</span>
<span className="cap-skill-card__main">
<span className="cap-skill-card__command">{skill.name}</span>
<span className="cap-skill-card__badges">
<span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
{skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
{!skill.enabled && <span className="cap-skill-badge cap-skill-badge--off">{t("caps.skillDisabled")}</span>}
</span>
</span>
</span>
</span>
</button>
<Tooltip label={skill.enabled ? t("caps.disableSkill") : t("caps.enableSkill")}>
<label className="cap-switch">
<input
type="checkbox"
checked={skill.enabled}
disabled={busy}
onChange={(e) => onToggleEnabled(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
</div>
<div className="cap-skill-card__desc">{expanded ? skill.description : summary}</div>
{canExpand && <div className="cap-skill-card__more">{expanded ? t("common.collapse") : t("common.expand")}</div>}
</button>
</div>
);
}
+173 -64
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, Search, Square, Trash2, X } from "lucide-react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { AlertTriangle, ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, List, Search, Square, Trash2, X, Zap } from "lucide-react";
import { asArray } from "../lib/array";
import { app, onFilesDropped } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import type { CommandInfo, ComposerInsertRequest, DirEntry, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
import type { CommandInfo, ComposerInsertRequest, DirEntry, EffortInfo, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
import {
formatWorkspaceReference,
parseWorkspaceReference,
@@ -14,7 +15,10 @@ import {
import { SlashMenu } from "./SlashMenu";
import { ArgMenu } from "./ArgMenu";
import { FileMenu } from "./FileMenu";
import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { Tooltip } from "./Tooltip";
import { AnchoredPopover } from "./AnchoredPopover";
interface Attachment {
path: string;
@@ -76,6 +80,27 @@ function loadComposerHeight(): number | null {
return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
}
function fmtTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
}
function fmtElapsed(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
function useTick(on: boolean): number {
const [, setN] = useState(0);
useEffect(() => {
if (!on) return;
const id = window.setInterval(() => setN((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [on]);
return Date.now();
}
function isImeKeyEvent(
e: KeyboardEvent<HTMLTextAreaElement>,
composing: boolean,
@@ -97,31 +122,56 @@ export function Composer({
running,
mode,
cwd,
modelLabel,
tabId,
effort,
onSend,
onCancel,
onCycleMode,
onSetMode,
onSwitchModel,
onSetEffort,
onPickFolder,
onRemoveWorkspace,
insertRequest,
disabled,
decisionPending = false,
ready,
turnStartAt,
turnTokens,
retry,
workspaceRefreshSignal,
}: {
running: boolean;
mode: Mode;
cwd?: string;
modelLabel: string;
tabId?: string;
effort?: EffortInfo;
onSend: (displayText: string, submitText?: string) => void;
// Returns the un-sent text when cancelling before the server replied (so it can
// be restored to the input); undefined for a normal cancel.
onCancel: () => string | undefined;
onCycleMode: () => void;
onSetMode: (mode: Mode) => void;
onSwitchModel: (name: string) => void;
onSetEffort: (level: string) => void;
onPickFolder: (path?: string) => Promise<string>;
onRemoveWorkspace: (path: string) => Promise<void>;
insertRequest?: ComposerInsertRequest | null;
disabled?: boolean;
decisionPending?: boolean;
// ready/cwd re-trigger the command fetch: Commands() returns only built-ins
// until boot.Build finishes (the controller, hence skills/custom/MCP, is nil
// before then), and the available set changes when the workspace switches.
ready?: boolean;
turnStartAt?: number;
turnTokens?: number;
retry?: { attempt: number; max: number };
workspaceRefreshSignal?: number;
}) {
const t = useT();
const { t, locale } = useI18n();
const now = useTick(running);
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
@@ -141,7 +191,6 @@ export function Composer({
const taRef = useRef<HTMLTextAreaElement>(null);
const composerCardRef = useRef<HTMLDivElement>(null);
const workspaceAnchorRef = useRef<HTMLDivElement>(null);
const workspaceMenuRef = useRef<HTMLDivElement>(null);
const wasRunning = useRef(running);
const composingRef = useRef(false);
const lastCompositionEndAt = useRef(0);
@@ -160,7 +209,7 @@ export function Composer({
// --- slash commands (whole-input "/token") ---
const [commands, setCommands] = useState<CommandInfo[]>([]);
useEffect(() => {
app.Commands().then(setCommands).catch(() => {});
app.Commands().then((next) => setCommands(asArray(next))).catch(() => {});
}, [ready, cwd]);
const slashQuery = useMemo(() => {
@@ -193,8 +242,10 @@ export function Composer({
// r.items can arrive as null (an empty Go slice serializes to JSON null),
// so guard before filtering — otherwise the throw is swallowed and the
// stale menu from the previous keystroke lingers (the /skill list bug).
const useful = (r.items ?? []).filter((it) => text.slice(0, r.from) + it.insert !== text);
setArgRes(useful.length > 0 ? { items: useful, from: r.from } : null);
const items = asArray(r?.items);
const from = r?.from ?? 0;
const useful = items.filter((it) => text.slice(0, from) + it.insert !== text);
setArgRes(useful.length > 0 ? { items: useful, from } : null);
setActive(0);
})
.catch(() => {});
@@ -237,7 +288,7 @@ export function Composer({
app
.ListDir(atDir)
.then((es) => {
const list = es ?? [];
const list = asArray(es);
dirCache.current[atDir] = list;
if (live) setEntries(list);
})
@@ -575,23 +626,12 @@ export function Composer({
}, [cwd]);
const loadWorkspaces = () => {
app.ListWorkspaces().then(setWorkspaces).catch(() => setWorkspaces([]));
app.ListWorkspaces().then((next) => setWorkspaces(asArray(next))).catch(() => setWorkspaces([]));
};
useEffect(() => {
if (workspaceMenuOpen) loadWorkspaces();
}, [workspaceMenuOpen, cwd]);
useEffect(() => {
if (!workspaceMenuOpen) return;
const close = (e: MouseEvent) => {
const target = e.target as Node;
if (workspaceAnchorRef.current?.contains(target) || workspaceMenuRef.current?.contains(target)) return;
setWorkspaceMenuOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [workspaceMenuOpen]);
}, [workspaceMenuOpen, cwd, workspaceRefreshSignal]);
const filteredWorkspaces = useMemo(() => {
const q = workspaceQuery.trim().toLowerCase();
@@ -607,6 +647,11 @@ export function Composer({
}
};
const removeWorkspace = async (path: string) => {
await onRemoveWorkspace(path);
setWorkspaces((prev) => prev.filter((w) => w.path !== path));
};
useEffect(() => {
const onResize = () => setComposerHeight((height) => (height === null ? null : clampComposerHeight(height)));
window.addEventListener("resize", onResize);
@@ -717,18 +762,48 @@ export function Composer({
}
// Esc interrupts the in-flight turn (matches the Stop button's hint), and
// restores the text if the server hadn't replied yet.
if (e.key === "Escape" && running) {
if (e.key === "Escape" && running && !decisionPending) {
e.preventDefault();
handleCancel();
}
};
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
const modeOptions: Array<{ id: Mode; label: string; icon: ReactNode }> = [
{ id: "normal", label: "auto", icon: <Zap size={13} /> },
{ id: "plan", label: "plan", icon: <List size={13} /> },
{ id: "yolo", label: "yolo", icon: <AlertTriangle size={13} /> },
];
const runActivity = retry
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
: running && turnStartAt
? (() => {
const elapsedMs = Math.max(0, now - turnStartAt);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
const hasWorkspace = Boolean(cwd);
const hasEffort = Boolean(effort?.supported);
const composerMetaClass = [
"composer-meta",
hasWorkspace ? "composer-meta--has-workspace" : "composer-meta--no-workspace",
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
].join(" ");
return (
<div className="composer-wrap" style={{ "--wails-drop-target": "drop" } as CSSProperties}>
{workspaceMenuOpen && cwd && (
<div className="workspace-switcher" ref={workspaceMenuRef}>
<div
className={`composer-wrap${decisionPending ? " composer-wrap--decision-pending" : ""}`}
style={{ "--wails-drop-target": "drop" } as CSSProperties}
>
<AnchoredPopover
open={workspaceMenuOpen && !!cwd}
anchorRef={workspaceAnchorRef}
onClose={() => setWorkspaceMenuOpen(false)}
className="workspace-switcher workspace-switcher--portal"
>
<label className="workspace-switcher__search">
<Search size={14} />
<input
@@ -743,9 +818,10 @@ export function Composer({
</label>
<div className="workspace-switcher__list">
{filteredWorkspaces.map((w) => (
<Tooltip key={w.path} label={w.path} fill>
<div className="workspace-switcher__row" key={w.path}>
<button
className="workspace-switcher__item"
className={`workspace-switcher__item${w.current ? " workspace-switcher__item--current" : ""}`}
title={w.path}
onClick={() => {
if (w.current) {
setWorkspaceMenuOpen(false);
@@ -758,7 +834,20 @@ export function Composer({
<span>{w.name}</span>
{w.current && <Check size={15} />}
</button>
</Tooltip>
<button
className="workspace-switcher__remove"
type="button"
aria-label={t("composer.removeProject")}
title={t("composer.removeProject")}
disabled={running}
onClick={(event) => {
event.stopPropagation();
void removeWorkspace(w.path);
}}
>
<Trash2 size={14} />
</button>
</div>
))}
{filteredWorkspaces.length === 0 && <div className="workspace-switcher__empty">{t("composer.noProjectMatches")}</div>}
</div>
@@ -768,8 +857,7 @@ export function Composer({
<span>{t("composer.addProject")}</span>
</button>
</div>
</div>
)}
</AnchoredPopover>
{menuMode === "slash" && (
<SlashMenu items={slashMatches} activeIndex={active} onPick={pickCommand} onHover={setActive} />
)}
@@ -777,6 +865,35 @@ export function Composer({
<ArgMenu items={argRes.items} activeIndex={active} onPick={pickArg} onHover={setActive} />
)}
{menuMode === "at" && <FileMenu items={atMatches} activeIndex={active} onPick={pickEntry} onHover={setActive} />}
<div className="composer-toolbar">
<div className="composer-modebar" role="toolbar" aria-label={t("composer.modeTitle")}>
{modeOptions.map((option) => (
<button
key={option.id}
type="button"
className={`composer-modebar__item composer-modebar__item--${option.id}${mode === option.id ? " composer-modebar__item--active" : ""}`}
onClick={() => onSetMode(option.id)}
aria-pressed={mode === option.id}
disabled={disabled || running}
>
{option.icon}
<span>{option.label}</span>
</button>
))}
</div>
{runActivity && (
<div className="composer-runstatus" role="status" aria-live="polite">
<span className="composer-runstatus__dot" />
<span className="composer-runstatus__text">{runActivity}</span>
<Tooltip label={t("composer.stop")}>
<button className="composer-runstatus__stop" type="button" onClick={handleCancel} disabled={decisionPending}>
<Square size={10} fill="currentColor" />
<span>{t("composer.stopShort")}</span>
</button>
</Tooltip>
</div>
)}
</div>
{(attachments.length > 0 || workspaceRefs.length > 0) && (
<div className="composer-context" aria-label={t("composer.contextItems")}>
{attachments.map((a) => (
@@ -893,13 +1010,7 @@ export function Composer({
rows={1}
disabled={disabled}
/>
{running ? (
<Tooltip label={t("composer.stop")}>
<button className="composer__btn composer__btn--stop" onClick={handleCancel}>
<Square size={14} fill="currentColor" />
</button>
</Tooltip>
) : (
{!running && (
<Tooltip label={t("composer.send")}>
<button
className="composer__btn composer__btn--send"
@@ -911,34 +1022,32 @@ export function Composer({
</Tooltip>
)}
</div>
<div className="composer-meta">
<div className={composerMetaClass}>
{cwd && (
<div className="composer-workspace-wrap" ref={workspaceAnchorRef}>
<Tooltip label={running ? t("common.busyHint") : t("status.switchFolder", { cwd })}>
<button
className={`composer__workspace${workspaceMenuOpen ? " composer__workspace--open" : ""}`}
onClick={() => {
if (!running) setWorkspaceMenuOpen((open) => !open);
}}
disabled={running}
>
<FolderGit2 size={13} />
<span>{workspaceName}</span>
<ChevronDown size={12} />
</button>
</Tooltip>
<div className="composer-meta__control composer-meta__control--workspace composer-workspace-wrap" ref={workspaceAnchorRef}>
<button
className={`composer__workspace${workspaceMenuOpen ? " composer__workspace--open" : ""}`}
onClick={() => {
if (!running) setWorkspaceMenuOpen((open) => !open);
}}
disabled={running}
>
<FolderGit2 size={13} />
<span>{workspaceName}</span>
<ChevronDown size={12} />
</button>
</div>
)}
<Tooltip label={t("composer.modeTitle")}>
<button
className={`composer__mode composer__mode--${mode}`}
onClick={onCycleMode}
>
<span className="composer__mode-dot" />
{mode === "yolo" ? t("composer.modeYolo") : mode === "plan" ? t("composer.modePlan") : t("composer.modeNormal")}
<span className="composer__mode-hint">{t("composer.modeHint")}</span>
</button>
</Tooltip>
<div className="composer-meta__params">
<div className="composer-meta__control composer-meta__control--model">
<ModelSwitcher label={modelLabel} tabId={tabId} onPick={onSwitchModel} />
</div>
{effort?.supported && (
<div className="composer-meta__control composer-meta__control--effort">
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
</div>
)}
</div>
</div>
</div>
</div>
@@ -0,0 +1,136 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
export type ContextMenuPoint = { left: number; top: number };
export type ContextMenuItem =
| {
type?: "item";
key: string;
icon?: ReactNode;
label: ReactNode;
disabled?: boolean;
danger?: boolean;
onSelect: () => void;
}
| {
type: "separator";
key: string;
};
const EDGE_GAP = 8;
function clampMenuPoint(left: number, top: number, width: number, height: number): ContextMenuPoint {
if (typeof window === "undefined") return { left, top };
return {
left: Math.min(Math.max(EDGE_GAP, left), Math.max(EDGE_GAP, window.innerWidth - width - EDGE_GAP)),
top: Math.min(Math.max(EDGE_GAP, top), Math.max(EDGE_GAP, window.innerHeight - height - EDGE_GAP)),
};
}
export function contextMenuPointFromEvent(
event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
): ContextMenuPoint {
if ("clientX" in event && event.clientX > 0 && event.clientY > 0) {
return { left: event.clientX, top: event.clientY };
}
const rect = event.currentTarget.getBoundingClientRect();
return { left: rect.left + 12, top: rect.bottom + 6 };
}
export function ContextMenu({
open,
point,
items,
onClose,
minWidth = 180,
ariaLabel = "Context menu",
}: {
open: boolean;
point: ContextMenuPoint | null;
items: ContextMenuItem[];
onClose: () => void;
minWidth?: number;
ariaLabel?: string;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<ContextMenuPoint | null>(point);
useLayoutEffect(() => {
if (!open || !point) return;
const rect = menuRef.current?.getBoundingClientRect();
if (!rect) {
setPosition(point);
return;
}
setPosition(clampMenuPoint(point.left, point.top, rect.width, rect.height));
}, [open, point, items]);
useEffect(() => {
if (!open) return;
const closeOnOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && menuRef.current?.contains(target)) return;
onClose();
};
const close = () => onClose();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
window.addEventListener("keydown", closeOnEscape);
return () => {
window.removeEventListener("pointerdown", closeOnOutsidePointerDown, true);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("keydown", closeOnEscape);
};
}, [open, onClose]);
if (!open || !point || !position) return null;
return createPortal(
<div
ref={menuRef}
className="context-menu"
role="menu"
aria-label={ariaLabel}
style={{ left: position.left, top: position.top, minWidth }}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{items.map((item) => {
if (item.type === "separator") {
return <div key={item.key} className="context-menu__separator" role="separator" />;
}
return (
<button
key={item.key}
type="button"
role="menuitem"
disabled={item.disabled}
className={`context-menu__item${item.danger ? " context-menu__item--danger" : ""}`}
onClick={(event) => {
event.stopPropagation();
if (!item.disabled) item.onSelect();
}}
>
{item.icon}
<span>{item.label}</span>
</button>
);
})}
</div>,
document.body,
);
}
@@ -0,0 +1,359 @@
// ContextPanel shows the active tab's context gauge, token usage, read files,
// and workspace changes. All visible text is routed through the i18n dictionary.
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, FileText, Search } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT, type Translator } from "../lib/i18n";
import type { DictKey } from "../locales/en";
import type { ContextInfo, ContextPanelInfo, WireUsage } from "../lib/types";
interface ContextPanelProps {
tabId?: string;
context?: ContextInfo;
usage?: WireUsage;
sessionCost?: number;
sessionCurrency?: string;
scopeLabel?: string;
refreshKey?: number;
}
type ContextDetail = "read" | "changed";
function fmtTokens(n: number): string {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
}
function fmtTime(ms?: number): string {
if (!ms) return "";
return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDuration(ms: number, t: Translator): string {
if (ms <= 0) return "-";
const totalSeconds = Math.max(1, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) return t("context.durationSeconds", { seconds });
return t("context.durationMinutesSeconds", { minutes, seconds });
}
function currencySymbol(currency?: string): string {
const value = (currency || "¥").trim();
if (/^(cny|rmb|yuan)$/i.test(value)) return "¥";
if (/^(usd|dollar)$/i.test(value)) return "$";
return value || "¥";
}
function fmtMoney(amount: number, currency?: string): string {
if (amount <= 0) return "-";
const symbol = currencySymbol(currency);
return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`;
}
interface HealthResult {
tone: "good" | "notice" | "warn";
labelKey: DictKey;
bodyKey: DictKey;
vars: Record<string, string | number>;
}
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
if (usagePct >= 85) {
return {
tone: "warn",
labelKey: "context.healthNearLimit",
bodyKey: "context.healthNearLimitBody",
vars: { pct: usagePct },
};
}
if (readCount >= 8) {
return {
tone: "notice",
labelKey: "context.healthManyFiles",
bodyKey: "context.healthManyFilesBody",
vars: { count: readCount },
};
}
if (cachePct > 0 && cachePct < 50) {
return {
tone: "notice",
labelKey: "context.healthLowCache",
bodyKey: "context.healthLowCacheBody",
vars: { pct: cachePct },
};
}
return {
tone: "good",
labelKey: "context.healthGood",
bodyKey: "context.healthGoodBody",
vars: {},
};
}
export function ContextPanel({ tabId, context, usage, sessionCost, sessionCurrency, scopeLabel, refreshKey }: ContextPanelProps) {
const t = useT();
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
const [detailView, setDetailView] = useState<ContextDetail | null>(null);
const [query, setQuery] = useState("");
const refresh = useCallback(async () => {
if (!tabId) return;
try {
setInfo(await app.ContextPanel(tabId));
} catch {
/* bridge unavailable */
}
}, [tabId]);
useEffect(() => {
const id = window.setInterval(() => void refresh(), 2000);
return () => window.clearInterval(id);
}, [refresh]);
useEffect(() => {
void refresh();
}, [refresh, refreshKey]);
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
const promptTokens = usage?.promptTokens && usage.promptTokens > 0 ? usage.promptTokens : info?.promptTokens ?? 0;
const completionTokens = usage?.completionTokens && usage.completionTokens > 0 ? usage.completionTokens : info?.completionTokens ?? 0;
const reasoningTokens = usage?.reasoningTokens && usage.reasoningTokens > 0 ? usage.reasoningTokens : info?.reasoningTokens ?? 0;
const cacheHitTokens = usage?.cacheHitTokens && usage.cacheHitTokens > 0 ? usage.cacheHitTokens : info?.cacheHitTokens ?? 0;
const cacheMissTokens = usage?.cacheMissTokens && usage.cacheMissTokens > 0 ? usage.cacheMissTokens : info?.cacheMissTokens ?? 0;
const cost = sessionCost && sessionCost > 0 ? sessionCost : info?.sessionCost ?? info?.sessionCostUsd ?? 0;
const currency = sessionCurrency || info?.sessionCurrency || usage?.currency || "¥";
const readFiles = asArray(info?.readFiles);
const changedFiles = asArray(info?.changedFiles);
const usagePct = windowTokens > 0 ? Math.round((usedTokens / windowTokens) * 100) : 0;
const cachePct = cacheHitTokens + cacheMissTokens > 0
? Math.round((cacheHitTokens / (cacheHitTokens + cacheMissTokens)) * 100)
: 0;
const otherTokens = Math.max(0, usedTokens - promptTokens - completionTokens - reasoningTokens);
const safeUsed = Math.max(usedTokens, 1);
const promptPct = Math.min(100, (promptTokens / safeUsed) * usagePct);
const completionPct = Math.min(100, promptPct + (completionTokens / safeUsed) * usagePct);
const reasoningPct = Math.min(100, completionPct + (reasoningTokens / safeUsed) * usagePct);
const otherPct = Math.min(100, reasoningPct + (otherTokens / safeUsed) * usagePct);
const donutStyle = {
background: `conic-gradient(#13a7a5 0 ${promptPct}%, #2f6df6 ${promptPct}% ${completionPct}%, #f97316 ${completionPct}% ${reasoningPct}%, var(--border) ${reasoningPct}% ${otherPct}%, var(--border-soft) ${otherPct}% 100%)`,
};
const eventTimes = [
...readFiles.map((file) => file.time),
...changedFiles.map((file) => file.latestTime ?? 0),
].filter((time) => time > 0);
const elapsed = eventTimes.length > 1 ? Math.max(...eventTimes) - Math.min(...eventTimes) : 0;
const requestCount = Math.max(readFiles.length + changedFiles.length, 0);
const readRows = readFiles.map((f, i) => ({
key: `${f.path}-${i}`,
path: f.path,
meta: `#${f.turn}`,
time: fmtTime(f.time),
detail: f.limit ? `${f.offset ?? 0}-${(f.offset ?? 0) + f.limit}${f.truncated ? " truncated" : ""}` : "",
}));
const changedRows = changedFiles.map((f, i) => ({
key: `${f.path}-${i}`,
path: f.path,
meta: f.gitStatus || asArray(f.sources).join(", ") || "changed",
time: fmtTime(f.latestTime),
detail: asArray(f.turns).length > 0 ? `T${asArray(f.turns).join(",")}` : "",
}));
const normalizedQuery = query.trim().toLowerCase();
const filterRows = (rows: typeof readRows) => {
if (!normalizedQuery) return rows;
return rows.filter((row) =>
`${row.path} ${row.meta} ${row.time} ${row.detail}`.toLowerCase().includes(normalizedQuery)
);
};
const filteredReadRows = filterRows(readRows);
const filteredChangedRows = filterRows(changedRows);
const health = contextHealth(usagePct, cachePct, readRows.length);
const detailRows = detailView === "changed" ? filteredChangedRows : filteredReadRows;
const detailTitle = detailView === "changed" ? t("context.sessionChanges") : t("context.referencedFiles");
const detailCount = detailView === "changed" ? changedRows.length : readRows.length;
const detailEmpty = detailView === "changed" ? t("context.noChanges") : t("context.noReads");
const detailPlaceholder = detailView === "changed" ? t("context.filterChanges") : t("context.filterReads");
const detailNote = detailView === "changed"
? t("context.changedNote", { count: detailCount })
: t("context.readNote", { count: detailCount });
const openDetail = (next: ContextDetail) => {
setDetailView(next);
setQuery("");
};
const closeDetail = () => {
setDetailView(null);
setQuery("");
};
return (
<div className="context-panel">
<div className="context-panel__summary-head">
<div className="context-panel__heading-main">
<span>{detailView ? detailTitle : t("context.overview")}</span>
<strong>{scopeLabel || t("context.scopeGlobal")}</strong>
</div>
{detailView && (
<button className="context-panel__back" type="button" onClick={closeDetail}>
<ArrowLeft size={13} />
{t("rightDock.overview")}
</button>
)}
</div>
{detailView && (
<label className="context-panel__search">
<Search size={14} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={detailPlaceholder} />
</label>
)}
<div className="context-panel__body">
{detailView ? (
<section className="context-panel__detail">
<div className="context-panel__detail-note">{detailNote}</div>
<FileTable
empty={detailEmpty}
rows={detailRows}
/>
</section>
) : (
<section className="context-panel__overview">
<section className="context-panel__usage">
<div className="context-panel__donut" style={donutStyle}>
<div className="context-panel__donut-core">
<strong>{fmtTokens(usedTokens)}</strong>
<span>/ {fmtTokens(windowTokens)} tokens</span>
</div>
</div>
<div className="context-panel__percent">{usagePct}%</div>
<div className="context-panel__breakdown">
<TokenLegend label={t("context.prompt")} value={promptTokens} color="prompt" />
<TokenLegend label={t("context.completion")} value={completionTokens} color="completion" />
<TokenLegend label={t("context.reasoning")} value={reasoningTokens} color="reasoning" />
<TokenLegend label={t("context.other")} value={otherTokens} color="other" />
<div className="context-panel__total">
<span>{t("context.total")}</span>
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
</div>
</div>
<div className="context-panel__stats">
<MetricCard label={t("context.cacheHit")} value={cachePct > 0 ? `${cachePct}%` : "-"} tone="accent" />
<MetricCard label={t("context.sessionCost")} value={fmtMoney(cost, currency)} />
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
</div>
</section>
<div className={`context-panel__health context-panel__health--${health.tone}`}>
<span>{t("context.health")}</span>
<strong>{t(health.labelKey, health.vars)}</strong>
<small>{t(health.bodyKey, health.vars)}</small>
</div>
<PreviewSection
title={t("context.referencedFiles")}
meta={t("context.readMeta", { count: readRows.length })}
action={t("context.viewAll")}
onAction={() => openDetail("read")}
rows={readRows.slice(0, 3)}
empty={t("context.noReads")}
/>
<PreviewSection
title={t("context.sessionChanges")}
meta={t("context.changedMeta", { count: changedRows.length })}
action={t("context.viewAll")}
onAction={() => openDetail("changed")}
rows={changedRows.slice(0, 3)}
empty={t("context.noChanges")}
/>
</section>
)}
</div>
<footer className="context-panel__scope">
<FileText size={14} />
<span>{scopeLabel || t("context.scopeGlobal")}</span>
</footer>
</div>
);
}
function PreviewSection({
title,
meta,
action,
onAction,
rows,
empty,
}: {
title: string;
meta?: string;
action: string;
onAction: () => void;
rows: Array<{ key: string; path: string; meta: string; time: string; detail: string }>;
empty: string;
}) {
return (
<section className="context-panel__preview">
<header className="context-panel__preview-head">
<h3>{title}</h3>
{meta && <span>{meta}</span>}
{rows.length > 0 && <button type="button" onClick={onAction}>{action}</button>}
</header>
<FileTable rows={rows} empty={empty} compact />
</section>
);
}
function TokenLegend({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="context-panel__legend-row">
<span className={`context-panel__legend-dot context-panel__legend-dot--${color}`} />
<span>{label}</span>
<strong>{value.toLocaleString()}</strong>
</div>
);
}
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: "accent" }) {
return (
<div className="context-panel__metric">
<span>{label}</span>
<strong className={tone === "accent" ? "context-panel__metric-accent" : ""}>{value}</strong>
</div>
);
}
function FileTable({
rows,
empty,
compact = false,
}: {
rows: Array<{ key: string; path: string; meta: string; time: string; detail: string }>;
empty: string;
compact?: boolean;
}) {
if (rows.length === 0) return <div className="context-panel__empty">{empty}</div>;
return (
<div className={`context-panel__file-list${compact ? " context-panel__file-list--compact" : ""}`}>
{rows.map((row) => (
<div className="context-panel__file-row" key={row.key}>
<span className="context-panel__file-main">
<FileText size={14} />
<span className="context-panel__file-copy">
<span>{row.path}</span>
{row.detail && <small>{row.detail}</small>}
</span>
</span>
<span className="context-panel__file-meta">
<span className="context-panel__file-turn">{row.meta}</span>
{row.time && <span>{row.time}</span>}
</span>
</div>
))}
</div>
);
}
@@ -1,7 +1,9 @@
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { useRef, useState } from "react";
import { Check, ChevronsUpDown, Gauge } from "lucide-react";
import { asArray } from "../lib/array";
import { useT } from "../lib/i18n";
import type { EffortInfo } from "../lib/types";
import { AnchoredPopover } from "./AnchoredPopover";
export function EffortSwitcher({
effort,
@@ -14,7 +16,9 @@ export function EffortSwitcher({
}) {
const t = useT();
const [open, setOpen] = useState(false);
if (!effort?.supported || effort.levels.length === 0) return null;
const triggerRef = useRef<HTMLButtonElement>(null);
const levels = asArray(effort?.levels);
if (!effort?.supported || levels.length === 0) return null;
const current = effort.current || "auto";
const pick = (level: string) => {
@@ -25,32 +29,40 @@ export function EffortSwitcher({
return (
<div className="modelsw effortsw">
<button
ref={triggerRef}
type="button"
className={`modelsw__trigger effortsw__trigger ${current !== "auto" ? "effortsw__trigger--explicit" : ""}`}
disabled={disabled}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Gauge size={13} className="modelsw__kind" />
<span className="modelsw__label">{t("status.effort", { level: current })}</span>
<ChevronsUpDown size={11} />
</button>
{open && !disabled && (
<>
<div className="modelsw__backdrop" onClick={() => setOpen(false)} />
<div className="modelsw__menu effortsw__menu" role="listbox">
{effort.levels.map((level) => (
<button
key={level}
role="option"
aria-selected={level === current}
className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`}
onClick={() => pick(level)}
>
<span className="modelsw__model">{level}</span>
{level === current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</>
)}
<AnchoredPopover
open={open && !disabled}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className="modelsw__menu modelsw__menu--portal effortsw__menu"
align="end"
>
<div role="listbox">
{levels.map((level) => (
<button
key={level}
type="button"
role="option"
aria-selected={level === current}
className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`}
onClick={() => pick(level)}
>
<span className="modelsw__model">{level}</span>
{level === current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</AnchoredPopover>
</div>
);
}
+225 -76
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Search, Trash2, Check, X } from "lucide-react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
import { t, useT } from "../lib/i18n";
import { sessionActivityTime } from "../lib/session";
import type { HistoryMessage, SessionMeta } from "../lib/types";
@@ -7,32 +8,47 @@ import type { Item } from "../lib/useController";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
import { Transcript } from "./Transcript";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
// HistoryPanel lists saved sessions newest-first. Idle clicks resume a session;
// running clicks load a read-only preview so the active stream keeps writing to
// the current controller/session.
export function HistoryPanel({
kind = "history",
sessions,
running,
onResume,
onPreview,
onDelete,
onRename,
onRestore,
onPurge,
onPurgeAll,
onClose,
}: {
kind?: "history" | "trash";
sessions: SessionMeta[];
running: boolean;
onResume: (path: string) => void;
onResume: (session: SessionMeta) => void;
onPreview: (path: string) => Promise<HistoryMessage[]>;
onDelete: (path: string) => void;
onRename: (path: string, title: string) => void;
onRestore?: (path: string) => void;
onPurge?: (path: string) => void;
onPurgeAll?: (paths: string[]) => void;
onClose: () => void;
}) {
const tr = useT();
const isTrash = kind === "trash";
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [confirming, setConfirming] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [menuSession, setMenuSession] = useState<SessionMeta | null>(null);
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
>(null);
const [preview, setPreview] = useState<{
path: string;
title: string;
@@ -44,7 +60,6 @@ export function HistoryPanel({
const startRename = (s: SessionMeta) => {
if (running) return;
setConfirming(null);
setEditing(s.path);
setDraft(s.title || s.preview || "");
};
@@ -57,11 +72,10 @@ export function HistoryPanel({
async (s: SessionMeta) => {
const seq = ++previewSeq.current;
setEditing(null);
setConfirming(null);
setPreview({
path: s.path,
title: sessionDisplayTitle(s, tr("history.emptySession")),
meta: sessionMetaLine(s, tr),
meta: sessionMetaLine(s, tr, isTrash),
messages: [],
loading: true,
});
@@ -70,7 +84,7 @@ export function HistoryPanel({
setPreview((cur) => (cur?.path === s.path ? { ...cur, messages, loading: false } : cur));
}
},
[onPreview, tr],
[isTrash, onPreview, tr],
);
const filteredSessions = useMemo(() => {
@@ -85,7 +99,7 @@ export function HistoryPanel({
// (Today / Yesterday / a date) while preserving that order.
const groups: { label: string; items: SessionMeta[] }[] = [];
for (const s of filteredSessions) {
const label = dayLabel(sessionActivityTime(s));
const label = dayLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
const last = groups[groups.length - 1];
if (last && last.label === label) last.items.push(s);
else groups.push({ label, items: [s] });
@@ -96,10 +110,16 @@ export function HistoryPanel({
if (!filteredSessions.some((s) => s.path === preview.path)) setPreview(null);
}, [filteredSessions, preview]);
useEffect(() => {
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(null);
setMenuConfirmTarget(null);
}, [isTrash]);
useEffect(() => {
if (!running) return;
setEditing(null);
setConfirming(null);
if (preview || filteredSessions.length === 0) return;
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
void loadPreview(first);
@@ -107,23 +127,173 @@ export function HistoryPanel({
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
const showPreview = preview !== null;
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
event.preventDefault();
event.stopPropagation();
setMenuConfirmTarget(null);
setBlankMenuPoint(null);
setMenuSession(s);
setMenuPoint(contextMenuPointFromEvent(event));
};
const openTrashBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!isTrash || sessions.length === 0) return;
const target = event.target as HTMLElement | null;
if (target?.closest(".hist-item,.history-search,.history-preview,button,input,textarea,select")) return;
event.preventDefault();
setMenuConfirmTarget(null);
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(contextMenuPointFromEvent(event));
};
const openTrashClearMenu = (event: ReactMouseEvent<HTMLElement>) => {
if (!isTrash || sessions.length === 0) return;
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
setMenuSession(null);
setMenuPoint(null);
setMenuConfirmTarget({ kind: "clear" });
setBlankMenuPoint({ left: rect.left, top: rect.bottom + 6 });
};
const closeHistoryMenus = () => {
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(null);
setMenuConfirmTarget(null);
};
const deleteHistorySession = (s: SessionMeta) => {
closeHistoryMenus();
onDelete(s.path);
};
const purgeTrashSession = (s: SessionMeta) => {
closeHistoryMenus();
onPurge?.(s.path);
};
const clearTrash = () => {
const paths = sessions.map((s) => s.path);
closeHistoryMenus();
onPurgeAll?.(paths);
};
const sessionMenuItems: ContextMenuItem[] = menuSession
? isTrash
? [
{
key: "restore",
icon: <RotateCcw size={13} />,
label: tr("history.restoreSession"),
onSelect: () => {
onRestore?.(menuSession.path);
closeHistoryMenus();
},
},
{ type: "separator", key: "trash-session-separator" },
{
key: "purge",
icon: <Trash2 size={13} />,
label:
menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path
? tr("history.confirmPurge")
: tr("history.purgeSession"),
danger: true,
onSelect: () => {
if (menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path) {
purgeTrashSession(menuSession);
} else {
setMenuConfirmTarget({ kind: "purge", path: menuSession.path });
}
},
},
]
: [
{
key: "rename",
icon: <Pencil size={13} />,
label: tr("history.rename"),
disabled: running,
onSelect: () => {
const target = menuSession;
closeHistoryMenus();
startRename(target);
},
},
...(menuSession.current
? []
: [
{
key: "delete",
icon: <Archive size={13} />,
label:
menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path
? tr("history.confirmMoveToTrash")
: tr("history.moveToTrash"),
disabled: running,
danger: menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path,
onSelect: () => {
if (menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path) {
deleteHistorySession(menuSession);
} else {
setMenuConfirmTarget({ kind: "delete", path: menuSession.path });
}
},
} as ContextMenuItem,
]),
]
: [];
const trashBlankMenuItems: ContextMenuItem[] =
menuConfirmTarget?.kind === "clear"
? [
{
key: "clear-trash-confirm",
icon: <Trash2 size={13} />,
label: tr("history.confirmClearTrash"),
danger: true,
onSelect: clearTrash,
},
]
: [
{
key: "clear-trash",
icon: <Trash2 size={13} />,
label: tr("history.clearTrashMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
},
];
return (
<ResizableDrawer onClose={onClose} wide={showPreview || running}>
<header className="drawer__head">
<div>
<div className="drawer__title">{tr("history.title")}</div>
{running && <div className="drawer__summary">{tr("history.readOnlyHint")}</div>}
<div className="drawer__title">{tr(isTrash ? "history.trashTitle" : "history.title")}</div>
{isTrash ? (
<div className="drawer__summary">{tr("history.trashHint")}</div>
) : (
running && <div className="drawer__summary">{tr("history.readOnlyHint")}</div>
)}
</div>
<div className="drawer__actions">
{isTrash && sessions.length > 0 && (
<button
className="chip history-clear"
type="button"
onClick={openTrashClearMenu}
>
{tr("history.clearTrash")}
</button>
)}
<Tooltip label={tr("common.close")}>
<button className="chip" onClick={onClose}>
✕
</button>
</Tooltip>
</div>
<Tooltip label={tr("common.close")}>
<button className="chip" onClick={onClose}>
✕
</button>
</Tooltip>
</header>
<div className={`drawer__body history-drawer${showPreview ? " history-drawer--preview" : ""}`}>
<div className="history-list">
<div
className={`drawer__body history-drawer${showPreview ? " history-drawer--preview" : ""}`}
onContextMenu={openTrashBlankMenu}
>
<div className={`history-list${isTrash ? " history-list--trash" : ""}`}>
{sessions.length > 0 && (
<label className="mem-search history-search">
<Search size={13} />
@@ -131,7 +301,10 @@ export function HistoryPanel({
</label>
)}
{sessions.length === 0 ? (
<div className="mem-empty">{tr("history.empty")}</div>
<div className={`mem-empty${isTrash ? " mem-empty--trash" : ""}`}>
{isTrash && <Trash2 size={22} />}
<span>{tr(isTrash ? "history.trashEmpty" : "history.empty")}</span>
</div>
) : filteredSessions.length === 0 ? (
<div className="mem-empty">{tr("history.noResults")}</div>
) : (
@@ -144,6 +317,7 @@ export function HistoryPanel({
<div
className={`hist-item${s.current ? " hist-item--current" : ""}${selected ? " hist-item--selected" : ""}`}
key={s.path}
onContextMenu={(event) => openSessionMenu(event, s)}
>
{editing === s.path ? (
<input
@@ -162,17 +336,23 @@ export function HistoryPanel({
<button
className="hist-item__main"
onClick={() => {
if (running) void loadPreview(s);
else onResume(s.path);
if (isTrash || running) void loadPreview(s);
else onResume(s);
}}
>
<div className="hist-item__preview">{sessionDisplayTitle(s, tr("history.emptySession"))}</div>
<div className="hist-item__meta">
{s.current && <span className="hist-item__badge">{tr("history.current")}</span>}
{!isTrash && s.current && <span className="hist-item__badge">{tr("history.current")}</span>}
<span>{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
<span>·</span>
<span>{timeLabel(sessionActivityTime(s))}</span>
{running && (
<span>{timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s))}</span>
{isTrash && s.deletedAt && (
<>
<span>·</span>
<span>{tr("history.deleted")}</span>
</>
)}
{!isTrash && running && (
<>
<span>·</span>
<span>{tr("history.preview")}</span>
@@ -182,55 +362,6 @@ export function HistoryPanel({
</button>
)}
{editing !== s.path && (
<div className="hist-item__actions">
{confirming === s.path ? (
<>
<Tooltip label={tr("history.confirmDelete")}>
<button
className="hist-act hist-act--danger"
disabled={running}
onClick={() => {
if (running) return;
onDelete(s.path);
setConfirming(null);
}}
>
<Check size={14} />
</button>
</Tooltip>
<Tooltip label={tr("common.cancel")}>
<button className="hist-act" onClick={() => setConfirming(null)}>
<X size={14} />
</button>
</Tooltip>
</>
) : (
<>
<Tooltip label={tr("history.rename")}>
<button
className="hist-act"
disabled={running}
onClick={() => startRename(s)}
>
<Pencil size={13} />
</button>
</Tooltip>
{!s.current && (
<Tooltip label={tr("common.delete")}>
<button
className="hist-act"
disabled={running}
onClick={() => setConfirming(s.path)}
>
<Trash2 size={13} />
</button>
</Tooltip>
)}
</>
)}
</div>
)}
</div>
);
})}
@@ -251,11 +382,27 @@ export function HistoryPanel({
) : previewItems.length === 0 ? (
<div className="mem-empty">{tr("history.previewEmpty")}</div>
) : (
<Transcript items={previewItems} onPrompt={() => {}} />
<Transcript items={previewItems} onPrompt={() => {}} questionNavigator={false} />
)}
</div>
</section>
)}
<ContextMenu
open={Boolean(menuSession)}
point={menuPoint}
items={sessionMenuItems}
minWidth={220}
ariaLabel={isTrash ? tr("history.trashSessionActions") : tr("history.historySessionActions")}
onClose={closeHistoryMenus}
/>
<ContextMenu
open={Boolean(blankMenuPoint)}
point={blankMenuPoint}
items={trashBlankMenuItems}
minWidth={220}
ariaLabel={tr("history.trashActions")}
onClose={closeHistoryMenus}
/>
</div>
</ResizableDrawer>
);
@@ -280,8 +427,10 @@ function sessionDisplayTitle(s: SessionMeta, fallback: string): string {
return s.title || s.preview || fallback;
}
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>): string {
return `${tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} · ${timeLabel(sessionActivityTime(s))}`;
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>, isTrash = false): string {
const time = timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
const suffix = isTrash && s.deletedAt ? ` · ${tr("history.deleted")}` : "";
return `${tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} · ${time}${suffix}`;
}
function previewMessagesToItems(messages: HistoryMessage[]): Item[] {
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
// Row-level destructive actions should confirm in place instead of opening a
// global modal. First click arms the action, second click confirms it, and the
// adjacent Cancel button or any disabled state returns the button to normal.
export function InlineConfirmButton({
label,
confirmLabel,
cancelLabel,
disabled = false,
danger = false,
onConfirm,
}: {
label: string;
confirmLabel: string;
cancelLabel: string;
disabled?: boolean;
danger?: boolean;
onConfirm: () => void | Promise<void>;
}) {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (disabled) setArmed(false);
}, [disabled]);
const run = async () => {
if (!armed) {
setArmed(true);
return;
}
setArmed(false);
await onConfirm();
};
return (
<span className="inline-confirm">
<button
className={`btn btn--small${armed && danger ? " btn--danger" : ""}`}
disabled={disabled}
type="button"
onClick={run}
>
{armed ? confirmLabel : label}
</button>
{armed && (
<button className="btn btn--small" disabled={disabled} type="button" onClick={() => setArmed(false)}>
{cancelLabel}
</button>
)}
</span>
);
}
@@ -83,7 +83,7 @@ export function MemoryPanel({
const scrollToFact = (name: string) => {
const el = factRefs.current[name];
if (!el) return;
el.scrollIntoView({ block: "center", behavior: "smooth" });
el.scrollIntoView({ block: "center", behavior: "auto" });
setHighlight(name);
window.setTimeout(() => setHighlight((h) => (h === name ? null : h)), 1200);
};
+121 -12
View File
@@ -1,49 +1,158 @@
import { memo, useState } from "react";
import { ChevronRight } from "lucide-react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Markdown } from "./Markdown";
import { CopyButton } from "./CopyButton";
import { Tooltip } from "./Tooltip";
import { useT } from "../lib/i18n";
import type { Item } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
type AssistantItem = Extract<Item, { kind: "assistant" }>;
export function UserMessage({
text,
turn,
anchorId,
open,
onToggle,
onRewind,
checkpoint,
actionPending = false,
rewindDisabled = false,
}: {
text: string;
turn?: number;
anchorId?: string;
open?: boolean; // whether this message's rewind menu is the open one (lifted to Transcript)
onToggle?: () => void;
onRewind?: (turn: number, scope: string) => void;
checkpoint?: CheckpointMeta;
actionPending?: boolean;
rewindDisabled?: boolean;
}) {
const t = useT();
const [confirmScope, setConfirmScope] = useState<string | null>(null);
const canRewind = onRewind != null && turn != null;
const rewind = (scope: string) => onRewind?.(turn as number, scope);
const actionDisabledReason = (scope: string): string => {
if (rewindDisabled || actionPending) return t("rewind.disabledRunning");
if (!checkpoint) return t("rewind.disabledNoCheckpoint");
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
return t("rewind.disabledNoBoundary");
}
if (scope === "summ-upto") {
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
}
if (scope === "code" && !checkpoint.canCode) return t("rewind.disabledNoCode");
if (scope === "both") {
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
if (!checkpoint.canCode) return t("rewind.disabledNoCode");
}
return "";
};
const actionLabel = (scope: string): string => {
if (confirmScope !== scope) {
switch (scope) {
case "fork":
return t("rewind.fork");
case "summ-from":
return t("rewind.summFrom");
case "summ-upto":
return t("rewind.summUpto");
case "conversation":
return t("rewind.conversation");
case "code":
return t("rewind.code");
default:
return t("rewind.both");
}
}
switch (scope) {
case "fork":
return t("rewind.confirmFork");
case "summ-from":
return t("rewind.confirmSummFrom");
case "summ-upto":
return t("rewind.confirmSummUpto");
case "conversation":
return t("rewind.confirmConversation");
case "code":
return t("rewind.confirmCode");
default:
return t("rewind.confirmBoth");
}
};
const actionMeta = (scope: string): string => {
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
return t("rewind.filesChanged", { count: checkpoint.files.length });
}
return "";
};
const runAction = (scope: string) => {
setConfirmScope(null);
onRewind?.(turn as number, scope);
};
const selectRewind = (scope: string) => {
if (actionDisabledReason(scope)) return;
if (confirmScope !== scope) {
setConfirmScope(scope);
return;
}
runAction(scope);
};
const renderAction = (scope: string, danger = false) => {
const disabledReason = actionDisabledReason(scope);
const meta = actionMeta(scope);
return (
<button
className={[
"rewind__menu-item",
danger ? "rewind__menu-danger" : "",
confirmScope === scope ? "rewind__menu-confirm" : "",
].filter(Boolean).join(" ")}
type="button"
disabled={Boolean(disabledReason)}
title={disabledReason || undefined}
onClick={() => selectRewind(scope)}
>
<span>{actionLabel(scope)}</span>
{meta && <span className="rewind__menu-meta">{meta}</span>}
</button>
);
};
const displayText = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]");
return (
<div className="msg msg--user">
<div className="msg msg--user" id={anchorId} data-question-anchor={anchorId} data-turn={turn}>
<span className="msg__caret">›</span>
<div className="msg__text">{displayText}</div>
{canRewind && (
<div className="rewind">
<div className={`rewind${open ? " rewind--open" : ""}`}>
<Tooltip label={t("rewind.label")}>
<button className="rewind__btn" onClick={onToggle}>
⟲
<button
className="rewind__btn"
type="button"
aria-label={t("rewind.label")}
aria-expanded={Boolean(open)}
onClick={() => {
setConfirmScope(null);
onToggle?.();
}}
>
<MoreHorizontal size={15} />
</button>
</Tooltip>
{open && (
<div className="rewind__menu">
<button onClick={() => rewind("both")}>{t("rewind.both")}</button>
<button onClick={() => rewind("conversation")}>{t("rewind.conversation")}</button>
<button onClick={() => rewind("code")}>{t("rewind.code")}</button>
<button onClick={() => rewind("fork")}>{t("rewind.fork")}</button>
<button onClick={() => rewind("summ-from")}>{t("rewind.summFrom")}</button>
<button onClick={() => rewind("summ-upto")}>{t("rewind.summUpto")}</button>
{rewindDisabled && <div className="rewind__menu-hint">{t("rewind.disabledRunning")}</div>}
{!rewindDisabled && !checkpoint && <div className="rewind__menu-hint">{t("rewind.disabledNoCheckpoint")}</div>}
{renderAction("fork")}
<div className="rewind__menu-separator" />
{renderAction("summ-from")}
{renderAction("summ-upto")}
<div className="rewind__menu-separator" />
{renderAction("conversation")}
{renderAction("code")}
{renderAction("both", true)}
</div>
)}
</div>
@@ -1,22 +1,24 @@
import { useEffect, useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Brain, Check, ChevronsUpDown } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import type { ModelInfo } from "../lib/types";
import { AnchoredPopover } from "./AnchoredPopover";
// ModelSwitcher is the bottom-of-window model picker: the status line's model
// label becomes a button that opens a popover (upward) listing configured
// providers. Selecting one switches the active model; the conversation is carried
// over by the backend, so the chat continues. Mirrors the "switch model, keep the
// session" behavior of comparable coding agents.
export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name: string) => void }) {
// ModelSwitcher opens an upward popover listing configured providers. Selecting
// one switches the active model while the current conversation continues.
export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?: string; onPick: (name: string) => void }) {
const t = useT();
const [open, setOpen] = useState(false);
const [models, setModels] = useState<ModelInfo[]>([]);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) app.Models().then(setModels).catch(() => {});
}, [open]);
if (open) {
(tabId ? app.ModelsForTab(tabId) : app.Models()).then((next) => setModels(asArray(next))).catch(() => {});
}
}, [open, tabId]);
const pick = (name: string) => {
setOpen(false);
@@ -25,30 +27,40 @@ export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name:
return (
<div className="modelsw">
<button className="modelsw__trigger" onClick={() => setOpen((v) => !v)}>
<button
ref={triggerRef}
type="button"
className="modelsw__trigger"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Brain size={13} className="modelsw__kind" />
<span className="modelsw__label">{label}</span>
<ChevronsUpDown size={11} />
</button>
{open && (
<>
<div className="modelsw__backdrop" onClick={() => setOpen(false)} />
<div className="modelsw__menu" role="listbox">
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
{models.map((m) => (
<button
key={m.ref}
role="option"
aria-selected={m.current}
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
onClick={() => pick(m.ref)}
>
<span className="modelsw__model">{m.model}</span>
{m.current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</>
)}
<AnchoredPopover
open={open}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className="modelsw__menu modelsw__menu--portal"
>
<div role="listbox">
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
{models.map((m) => (
<button
key={m.ref}
type="button"
role="option"
aria-selected={m.current}
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
onClick={() => pick(m.ref)}
>
<span className="modelsw__model">{m.model}</span>
{m.current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
</AnchoredPopover>
</div>
);
}
@@ -0,0 +1,811 @@
// ProjectTree is the sidebar replacement for the flat recent-sessions list.
// It shows a tree of projects (each with expandable topics) plus a Global
// section. Clicking a topic opens its tab; "+" next to a project creates a
// new topic.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
import { Archive, ChevronRight, ChevronDown, Pencil, Plus, Folder, FolderGit2, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, History, Check } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import type { ProjectNode } from "../lib/types";
import { getLocale, useT, type Translator } from "../lib/i18n";
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
import { Tooltip } from "./Tooltip";
interface ProjectTreeProps {
activeScope?: string;
activeWorkspaceRoot?: string;
activeTopicId?: string;
currentWorkspaceName?: string;
onOpenTopic: (scope: string, workspaceRoot: string, topicId: string) => Promise<void> | void;
onOpenProjectHistory: (scope: "global" | "project", workspaceRoot: string) => Promise<void> | void;
onAddProject: () => Promise<void>;
onUseCurrentProject?: () => Promise<void>;
onRenameTopic?: (topicId: string, title: string) => Promise<void> | void;
onTopicsChanged?: () => Promise<void> | void;
refreshSignal?: number;
}
function projectNodeKey(node: ProjectNode, depth: number): string {
return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`;
}
function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string): boolean {
if (node.kind !== "topic" && node.kind !== "global_topic") return false;
const scope = node.kind === "global_topic" ? "global" : "project";
return (
activeTopicId === node.topicId &&
activeScope === scope &&
(scope === "global" || activeWorkspaceRoot === node.root)
);
}
function topicMetaLine(node: ProjectNode, t: Translator): string {
const turns = node.turns ?? 0;
if (turns <= 0) return "";
const last = node.lastActivityAt ? ` · ${topicActivityLabel(node.lastActivityAt)}` : "";
return `${t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns })}${last}`;
}
function topicActivityLabel(ms: number): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
const locale = getLocale();
const rtf = new Intl.RelativeTimeFormat(locale === "zh" ? "zh-CN" : "en", { numeric: "auto" });
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
if (delta < minute) return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute");
if (delta < day) return rtf.format(-Math.round(delta / hour), "hour");
if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day");
return new Date(ms).toLocaleDateString();
}
type ProjectDropPosition = "before" | "after";
function projectRoots(nodes: ProjectNode[]): string[] {
return nodes
.filter((node) => node.kind === "project" && Boolean(node.root))
.map((node) => node.root!);
}
function reorderedProjectRoots(nodes: ProjectNode[], draggedRoot: string, targetRoot: string, position: ProjectDropPosition): string[] {
const roots = projectRoots(nodes);
if (draggedRoot === targetRoot || !roots.includes(draggedRoot) || !roots.includes(targetRoot)) return roots;
const next = roots.filter((root) => root !== draggedRoot);
const targetIndex = next.indexOf(targetRoot);
if (targetIndex < 0) return roots;
next.splice(position === "before" ? targetIndex : targetIndex + 1, 0, draggedRoot);
return next;
}
function applyProjectOrder(nodes: ProjectNode[], roots: string[]): ProjectNode[] {
const byRoot = new Map(nodes.filter((node) => node.kind === "project" && node.root).map((node) => [node.root!, node]));
const orderedProjects = roots.map((root) => byRoot.get(root)).filter((node): node is ProjectNode => Boolean(node));
const nonProjects = nodes.filter((node) => node.kind !== "project");
return [...nonProjects, ...orderedProjects];
}
function projectAccentStyle(color?: string): CSSProperties | undefined {
const value = projectColorValue(color);
if (!value) return undefined;
return { "--project-accent": value } as CSSProperties;
}
function colorMenuLabel(label: string, color?: string, active = false) {
const value = projectColorValue(color);
return (
<span className="project-tree__color-option">
<span
className="project-tree__color-swatch"
style={value ? ({ "--project-accent": value } as CSSProperties) : undefined}
aria-hidden="true"
/>
<span>{label}</span>
{active && <Check className="project-tree__color-check" size={12} />}
</span>
);
}
function revealLabelKey(platform: string): "projectTree.revealInFinder" | "projectTree.revealInExplorer" | "projectTree.revealInFileManager" {
if (platform === "darwin") return "projectTree.revealInFinder";
if (platform === "windows") return "projectTree.revealInExplorer";
return "projectTree.revealInFileManager";
}
function projectColorLabel(t: Translator, color?: string): string {
switch (color) {
case "red": return t("projectTree.colorRed");
case "orange": return t("projectTree.colorOrange");
case "amber": return t("projectTree.colorAmber");
case "green": return t("projectTree.colorGreen");
case "teal": return t("projectTree.colorTeal");
case "blue": return t("projectTree.colorBlue");
case "purple": return t("projectTree.colorPurple");
case "pink": return t("projectTree.colorPink");
default: return t("projectTree.colorDefault");
}
}
export function ProjectTree({
activeScope,
activeWorkspaceRoot,
activeTopicId,
currentWorkspaceName,
onOpenTopic,
onOpenProjectHistory,
onAddProject,
onUseCurrentProject,
onRenameTopic,
onTopicsChanged,
refreshSignal,
}: ProjectTreeProps) {
const t = useT();
const [tree, setTree] = useState<ProjectNode[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
const [creatingProject, setCreatingProject] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [editingTopic, setEditingTopic] = useState<string | null>(null);
const [topicDraft, setTopicDraft] = useState("");
const [menuTopic, setMenuTopic] = useState<string | null>(null);
const [menuProject, setMenuProject] = useState<{ key: string; root: string; path: string; scope: "global" | "project"; label: string } | null>(null);
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const [editingProject, setEditingProject] = useState<{ key: string; root: string } | null>(null);
const [projectDraft, setProjectDraft] = useState("");
const [addingProject, setAddingProject] = useState(false);
const [confirmAction, setConfirmAction] = useState<{ topicId: string; action: "trash" } | null>(null);
const [confirmRemoveProject, setConfirmRemoveProject] = useState<string | null>(null);
const [dragProjectRoot, setDragProjectRoot] = useState<string | null>(null);
const [dropProject, setDropProject] = useState<{ root: string; position: ProjectDropPosition } | null>(null);
const [platform, setPlatform] = useState("");
const creatingRef = useRef(false);
const closeMenu = useCallback(() => {
setMenuTopic(null);
setMenuProject(null);
setMenuPoint(null);
setConfirmAction(null);
setConfirmRemoveProject(null);
}, []);
const refresh = useCallback(async () => {
try {
const nodes = await app.ListProjectTree();
const list = asArray(nodes);
setTree(list);
setExpanded((prev) => {
const next = new Set(prev);
for (const node of list) {
if (node?.key && !manuallyCollapsed.has(node.key)) next.add(node.key);
}
return next;
});
} catch {
/* bridge unavailable */
}
}, [manuallyCollapsed]);
useEffect(() => {
void refresh();
}, [refresh, refreshSignal]);
useEffect(() => {
let cancelled = false;
void app.Platform().then((value) => {
if (!cancelled) setPlatform(value);
}).catch(() => {});
return () => {
cancelled = true;
};
}, []);
const toggleExpand = (key: string) => {
const willCollapse = expanded.has(key);
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
setManuallyCollapsed((prev) => {
const next = new Set(prev);
if (willCollapse) next.add(key);
else next.delete(key);
return next;
});
};
const handleAddProject = async () => {
if (addingProject) return;
setAddingProject(true);
try {
await onAddProject();
await refresh();
} finally {
setAddingProject(false);
}
};
const handleCreateTopic = async (scope: string, workspaceRoot: string, key: string) => {
if (creatingRef.current) return;
creatingRef.current = true;
setCreatingProject(key);
setMenuProject(null);
setMenuPoint(null);
setExpanded((prev) => {
const next = new Set(prev);
next.add(key);
return next;
});
setManuallyCollapsed((prev) => {
if (!prev.has(key)) return prev;
const next = new Set(prev);
next.delete(key);
return next;
});
try {
const topic = await app.CreateTopic(scope, workspaceRoot, "");
await refresh();
await onTopicsChanged?.();
await onOpenTopic(scope, workspaceRoot, topic.id);
} catch {
/* ignore */
} finally {
creatingRef.current = false;
setCreatingProject(null);
}
};
const startRenameTopic = (node: ProjectNode, label: string) => {
setMenuTopic(null);
setMenuProject(null);
setMenuPoint(null);
setConfirmAction(null);
setEditingTopic(node.topicId ?? null);
setTopicDraft(label);
};
const startRenameProject = (key: string, root: string, label: string) => {
setMenuProject(null);
setMenuTopic(null);
setMenuPoint(null);
setConfirmRemoveProject(null);
setEditingProject({ key, root });
setProjectDraft(label);
};
const commitRenameTopic = async (topicId: string) => {
const title = topicDraft.trim();
setEditingTopic(null);
if (!title) return;
try {
if (onRenameTopic) await onRenameTopic(topicId, title);
else await app.RenameTopic(topicId, title);
await refresh();
if (!onRenameTopic) await onTopicsChanged?.();
} catch {
/* ignore */
}
};
const commitRenameProject = async (root: string) => {
const title = projectDraft.trim();
setEditingProject(null);
if (!title) return;
try {
await app.RenameProject(root, title);
await refresh();
} catch {
/* ignore */
}
};
const trashTopic = async (topicId: string) => {
try {
await app.TrashTopic(topicId);
setMenuTopic(null);
setMenuPoint(null);
setConfirmAction(null);
await refresh();
await onTopicsChanged?.();
} catch {
/* ignore */
}
};
const copyProjectPath = async (path: string) => {
if (!path) return;
try {
await navigator.clipboard?.writeText(path);
} catch {
/* ignore */
}
};
const removeProject = async (path: string) => {
if (!path) return;
try {
await app.RemoveWorkspace(path);
setMenuProject(null);
setMenuPoint(null);
setConfirmRemoveProject(null);
await refresh();
} catch {
/* ignore */
}
};
const setProjectColor = async (path: string, color: string) => {
try {
await app.SetProjectColor(path, color);
setMenuProject(null);
setMenuPoint(null);
await refresh();
await onTopicsChanged?.();
} catch {
/* ignore */
}
};
const visibleTree = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return tree;
const matches = (node: ProjectNode) =>
[node.label, node.root, node.topicId].some((value) => (value ?? "").toLowerCase().includes(q));
const filterNode = (node: ProjectNode): ProjectNode | null => {
const children = asArray(node.children)
.map(filterNode)
.filter((child): child is ProjectNode => child !== null);
if (matches(node) || children.length > 0) return { ...node, children };
return null;
};
return tree
.map(filterNode)
.filter((node): node is ProjectNode => node !== null);
}, [query, tree]);
const projectDragEnabled = query.trim() === "";
const commitProjectReorder = useCallback(async (draggedRoot: string, targetRoot: string, position: ProjectDropPosition) => {
const nextRoots = reorderedProjectRoots(tree, draggedRoot, targetRoot, position);
const currentRoots = projectRoots(tree);
if (nextRoots.join("\n") === currentRoots.join("\n")) return;
setTree((current) => applyProjectOrder(current, nextRoots));
try {
await app.ReorderProjects(nextRoots);
await refresh();
await onTopicsChanged?.();
} catch {
await refresh();
}
}, [onTopicsChanged, refresh, tree]);
const clearProjectDrag = useCallback(() => {
setDragProjectRoot(null);
setDropProject(null);
}, []);
const activeAncestorKeys = useMemo(() => {
const walk = (nodes: ProjectNode[], ancestors: string[]): string[] | null => {
for (const node of nodes) {
if (!node) continue;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId)) return ancestors;
const children = asArray(node.children);
if (children.length > 0) {
const next = walk(children, [...ancestors, projectNodeKey(node, ancestors.length)]);
if (next) return next;
}
}
return null;
};
return walk(tree, []) ?? [];
}, [activeScope, activeTopicId, activeWorkspaceRoot, tree]);
useEffect(() => {
if (activeAncestorKeys.length === 0) return;
setExpanded((prev) => {
let changed = false;
const next = new Set(prev);
for (const key of activeAncestorKeys) {
if (manuallyCollapsed.has(key) || next.has(key)) continue;
next.add(key);
changed = true;
}
return changed ? next : prev;
});
}, [activeAncestorKeys, manuallyCollapsed]);
const renderNode = (node: ProjectNode | null | undefined, depth: number) => {
if (!node) return null;
const key = projectNodeKey(node, depth);
const children = asArray(node.children);
const isExpanded = query.trim() ? true : expanded.has(key);
const hasChildren = children.length > 0;
if (node.kind === "topic" || node.kind === "global_topic") {
const scope = node.kind === "global_topic" ? "global" : "project";
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId);
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
const meta = topicMetaLine(node, t);
const topicId = node.topicId ?? "";
const topicMenuOpen = menuTopic === topicId;
const openTopicMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuProject(null);
setConfirmRemoveProject(null);
setMenuPoint(contextMenuPointFromEvent(event));
setMenuTopic(topicId);
setConfirmAction(null);
};
const topicMenuItems: ContextMenuItem[] = [
{
key: "rename",
icon: <Pencil size={13} />,
label: t("projectTree.renameTopic"),
onSelect: () => startRenameTopic(node, label),
},
{
key: "trash",
icon: <Archive size={13} />,
label: confirmAction?.topicId === topicId && confirmAction.action === "trash" ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
danger: true,
onSelect: () => {
if (confirmAction?.topicId === topicId && confirmAction.action === "trash") void trashTopic(topicId);
else setConfirmAction({ topicId, action: "trash" });
},
},
];
if (editingTopic === topicId) {
return (
<div
key={key}
className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}`}
style={{ paddingLeft: 14 + depth * 16 }}
>
<input
autoFocus
className="project-tree__topic-input"
value={topicDraft}
onChange={(event) => setTopicDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRenameTopic(topicId);
if (event.key === "Escape") setEditingTopic(null);
}}
onBlur={() => void commitRenameTopic(topicId)}
/>
</div>
);
}
return (
<div
key={key}
className={`project-tree__topic${active ? " project-tree__topic--active" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}`}
style={projectAccentStyle(node.projectColor)}
onContextMenu={openTopicMenu}
>
<button
type="button"
className="project-tree__topic-main"
title={meta ? `${label} · ${meta}` : label}
style={{ paddingLeft: 14 + depth * 16 }}
onClick={() => onOpenTopic(scope, node.root ?? "", topicId)}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openTopicMenu(event);
}
}}
>
<span className="project-tree__topic-copy">
<span className="project-tree__topic-label">{label}</span>
</span>
</button>
<ContextMenu
open={topicMenuOpen}
point={menuPoint}
items={topicMenuItems}
minWidth={178}
ariaLabel={t("projectTree.topicActions")}
onClose={closeMenu}
/>
</div>
);
}
const scope = node.kind === "global_folder" ? "global" : "project";
const projectRoot = scope === "global" ? "" : node.root ?? "";
const projectPath = node.root ?? "";
const colorTargetRoot = scope === "global" ? "" : projectPath;
const projectLabel = node.label || (scope === "global" ? "Global" : "Untitled");
const projectActive = activeScope === scope && (scope === "global" || activeWorkspaceRoot === node.root);
const draggableProject = projectDragEnabled && scope === "project" && depth === 0 && Boolean(projectRoot) && editingProject?.key !== key;
const projectDropPosition = dropProject?.root === projectRoot ? dropProject.position : null;
const handleProjectDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
if (!draggableProject) return;
const target = event.target as HTMLElement | null;
if (target?.closest("button,input,textarea,select")) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", projectRoot);
setDragProjectRoot(projectRoot);
setDropProject(null);
};
const handleProjectDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!draggableProject || !dragProjectRoot || dragProjectRoot === projectRoot) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const rect = event.currentTarget.getBoundingClientRect();
const position: ProjectDropPosition = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
setDropProject((current) => {
if (current?.root === projectRoot && current.position === position) return current;
return { root: projectRoot, position };
});
};
const handleProjectDrop = (event: ReactDragEvent<HTMLDivElement>) => {
if (!draggableProject) return;
const draggedRoot = dragProjectRoot || event.dataTransfer.getData("text/plain");
const position = dropProject?.root === projectRoot ? dropProject.position : "after";
event.preventDefault();
clearProjectDrag();
if (draggedRoot && draggedRoot !== projectRoot) void commitProjectReorder(draggedRoot, projectRoot, position);
};
const openProjectMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuTopic(null);
setConfirmAction(null);
setMenuPoint(contextMenuPointFromEvent(event));
setMenuProject({ key, root: projectRoot, path: projectPath, scope, label: projectLabel });
setConfirmRemoveProject(null);
};
const projectMenuItems: ContextMenuItem[] = [
{
key: "new-session",
icon: <Plus size={13} />,
label: t("projectTree.newTopic"),
onSelect: () => {
void handleCreateTopic(scope, projectRoot, key);
},
},
...(scope === "project"
? [
{
key: "project-history",
icon: <History size={13} />,
label: t("projectTree.projectHistory"),
onSelect: () => {
closeMenu();
void onOpenProjectHistory(scope, projectRoot);
},
},
]
: []),
{
key: "rename",
icon: <Pencil size={13} />,
label: t("projectTree.renameProject"),
onSelect: () => startRenameProject(key, projectRoot, projectLabel),
},
{ type: "separator" as const, key: "color-separator" },
...PROJECT_COLOR_OPTIONS.map((option): ContextMenuItem => ({
key: `color-${option.key || "default"}`,
label: colorMenuLabel(projectColorLabel(t, option.key), option.key, (node.projectColor || "") === option.key),
onSelect: () => {
void setProjectColor(colorTargetRoot, option.key);
},
})),
{ type: "separator" as const, key: "path-separator" },
{
key: "reveal",
icon: <FolderOpen size={13} />,
label: t(revealLabelKey(platform)),
disabled: !projectPath,
onSelect: () => {
void app.RevealPath(projectPath);
closeMenu();
},
},
{
key: "copy-path",
icon: <Copy size={13} />,
label: t("projectTree.copyPath"),
disabled: !projectPath,
onSelect: () => {
void copyProjectPath(projectPath);
closeMenu();
},
},
...(scope === "project"
? [
{ type: "separator" as const, key: "remove-separator" },
{
key: "remove",
icon: <XCircle size={13} />,
label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProject") : t("projectTree.removeProject"),
danger: true,
onSelect: () => {
if (confirmRemoveProject === key) void removeProject(projectPath);
else setConfirmRemoveProject(key);
},
},
]
: []),
];
if (editingProject?.key === key) {
return (
<div key={key}>
<div
className={`project-tree__folder project-tree__folder--editing${projectActive ? " project-tree__folder--active" : ""}`}
style={{ paddingLeft: 8 + depth * 16 }}
>
<input
autoFocus
className="project-tree__folder-input"
value={projectDraft}
onChange={(event) => setProjectDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRenameProject(projectRoot);
if (event.key === "Escape") setEditingProject(null);
}}
onBlur={() => void commitRenameProject(projectRoot)}
/>
</div>
{isExpanded && hasChildren && (
<div className="project-tree__children">
{children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
}
return (
<div key={key}>
<div
className={`project-tree__folder${projectActive ? " project-tree__folder--active" : ""}${menuProject?.key === key ? " project-tree__folder--menu-open" : ""}${dragProjectRoot === projectRoot ? " project-tree__folder--dragging" : ""}${projectDropPosition ? ` project-tree__folder--drop-${projectDropPosition}` : ""}`}
style={projectAccentStyle(node.projectColor)}
draggable={draggableProject}
aria-grabbed={draggableProject ? dragProjectRoot === projectRoot : undefined}
onDragStart={handleProjectDragStart}
onDragOver={handleProjectDragOver}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropProject(null);
}}
onDrop={handleProjectDrop}
onDragEnd={clearProjectDrag}
onContextMenu={openProjectMenu}
>
<button
type="button"
className="project-tree__folder-main"
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => {
if (hasChildren) toggleExpand(key);
}}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openProjectMenu(event);
}
}}
aria-expanded={hasChildren ? isExpanded : undefined}
>
{hasChildren ? (
isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />
) : (
<span style={{ width: 12 }} />
)}
<Folder size={12} />
<span className="project-tree__folder-color" aria-hidden="true" />
<span className="project-tree__folder-label">{projectLabel}</span>
</button>
<Tooltip label={t("projectTree.newTopicTooltip")} className="project-tree__action-slot">
<button
type="button"
className={`project-tree__new-topic${creatingProject === key ? " project-tree__new-topic--active" : ""}`}
disabled={creatingProject !== null}
onClick={(e) => {
e.stopPropagation();
void handleCreateTopic(scope, projectRoot, key);
}}
>
<Plus size={12} />
</button>
</Tooltip>
<ContextMenu
open={menuProject?.key === key}
point={menuPoint}
items={projectMenuItems}
minWidth={212}
ariaLabel={t("projectTree.projectActions")}
onClose={closeMenu}
/>
</div>
{isExpanded && hasChildren && (
<div className="project-tree__children">
{children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
return (
<div className="project-tree">
<label className="project-tree__search">
<Search size={14} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("projectTree.searchPlaceholder")}
/>
</label>
<div className="project-tree__header">
<span className="project-tree__header-title">
<BriefcaseBusiness size={13} />
{t("projectTree.workspaceTitle")}
</span>
<Tooltip label={t("projectTree.addProjectTooltip")} className="project-tree__action-slot">
<button
type="button"
className="project-tree__add-project"
aria-label={t("projectTree.addProjectTooltip")}
disabled={addingProject}
onClick={() => void handleAddProject()}
>
<FolderPlus size={13} />
</button>
</Tooltip>
</div>
<div className="project-tree__list">
{visibleTree.length === 0 ? (
query.trim() ? (
<div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>
) : (
<div className="project-tree__empty-state">
<div className="project-tree__empty-icon">
<FolderPlus size={18} />
</div>
<div className="project-tree__empty-copy">
<strong>{t("projectTree.emptyNoProjects")}</strong>
<span>{t("projectTree.emptyNoProjectsDesc")}</span>
</div>
<div className="project-tree__empty-actions">
<button
type="button"
className="project-tree__empty-primary"
onClick={() => void handleAddProject()}
disabled={addingProject}
>
<FolderPlus size={14} />
<span>{t("projectTree.emptyAddProject")}</span>
</button>
{onUseCurrentProject && currentWorkspaceName && (
<button
type="button"
className="project-tree__empty-secondary"
onClick={async () => {
await onUseCurrentProject();
await refresh();
}}
>
<FolderGit2 size={14} />
<span>{t("projectTree.emptyUseCurrentDir")}</span>
</button>
)}
</div>
{currentWorkspaceName && <div className="project-tree__empty-current">{t("projectTree.emptyCurrentDir", { dir: currentWorkspaceName })}</div>}
</div>
)
) : (
visibleTree.map((node) => renderNode(node, 0))
)}
</div>
</div>
);
}
@@ -11,6 +11,7 @@ export function PromptShelf({
crumbs,
quickActions,
barRef,
actionsWrap = false,
}: {
titleId: string;
title: ReactNode;
@@ -21,9 +22,10 @@ export function PromptShelf({
crumbs?: ReactNode;
quickActions?: ReactNode;
barRef?: RefObject<HTMLDivElement>;
actionsWrap?: boolean;
}) {
return (
<div className="prompt-shelf" aria-live="polite">
<div className={`prompt-shelf${actionsWrap ? " prompt-shelf--actions-wrap" : ""}`} aria-live="polite">
<div
ref={barRef}
className="prompt-shelf__bar"
@@ -36,8 +38,8 @@ export function PromptShelf({
<PauseCircle size={16} aria-hidden="true" />
<div className="prompt-shelf__copy">
<div id={titleId} className="prompt-shelf__title">
{title}
{badges}
<span className="prompt-shelf__heading">{title}</span>
{badges && <span className="prompt-shelf__badges">{badges}</span>}
</div>
<div className="prompt-shelf__meta">{meta}</div>
</div>
+132 -25
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useI18n, useT } from "../lib/i18n";
import { normalizeLangPref, useI18n, useT, type LangPref } from "../lib/i18n";
import { useUpdater } from "../lib/useUpdater";
import {
THEME_STYLES,
@@ -9,17 +10,20 @@ import {
getResolvedTheme,
getTheme,
getThemeStyle,
normalizeThemePreference,
normalizeThemeStyleForTheme,
themeForStyle,
type Theme,
type ThemeStyle,
} from "../lib/theme";
import type { NetworkView, ProviderView, SettingsView } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
type SettingsTab = "models" | "providers" | "network" | "permissions" | "sandbox" | "agent" | "appearance" | "updates";
type SettingsTab = "general" | "models" | "providers" | "network" | "permissions" | "sandbox" | "agent" | "appearance" | "updates";
const SETTINGS_TABS: SettingsTab[] = ["models", "providers", "network", "permissions", "sandbox", "agent", "appearance", "updates"];
const SETTINGS_TABS: SettingsTab[] = ["general", "models", "providers", "network", "permissions", "sandbox", "agent", "appearance", "updates"];
// SettingsPanel is the desktop settings surface, aligning with Claude Code's
// settings: model & providers (incl. API keys), permissions, sandbox, agent
@@ -32,12 +36,21 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC
const [err, setErr] = useState<string | null>(null);
const [theme, setThemeState] = useState<Theme>(getTheme());
const [themeStyle, setThemeStyleState] = useState<ThemeStyle>(() => getThemeStyle(getTheme()));
const [tab, setTab] = useState<SettingsTab>("models");
const [tab, setTab] = useState<SettingsTab>("general");
const [platform, setPlatform] = useState("darwin");
const reload = async () => setS(await app.Settings().catch(() => null));
const reload = async () => setS(normalizeSettingsView(await app.Settings().catch(() => null)));
useEffect(() => {
void reload();
void app.Platform().then(setPlatform).catch(() => setPlatform("darwin"));
}, []);
useEffect(() => {
if (!s) return;
const nextTheme = normalizeThemePreference(s.desktopTheme);
const nextStyle = normalizeThemeStyleForTheme(s.desktopThemeStyle, nextTheme);
setThemeState(nextTheme);
setThemeStyleState(nextStyle);
}, [s?.desktopTheme, s?.desktopThemeStyle]);
// apply runs a mutation, re-reads settings, and refreshes the topbar/model. A
// rejected binding (validation / rebuild failure) surfaces as an inline banner.
@@ -85,6 +98,7 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC
</nav>
<main className="settings-content">
{err && <div className="banner banner--error">{err}</div>}
{tab === "general" && <GeneralSection s={s} busy={busy} apply={apply} platform={platform} />}
{tab === "models" && <ModelsSection s={s} busy={busy} apply={apply} onManageProviders={() => setTab("providers")} />}
{tab === "providers" && <ProvidersSection s={s} busy={busy} apply={apply} />}
{tab === "network" && <NetworkSection s={s} busy={busy} apply={apply} />}
@@ -97,15 +111,17 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC
themeStyle={themeStyle}
onTheme={(t) => {
const nextStyle = themeForStyle(themeStyle) === getResolvedTheme(t) ? themeStyle : defaultStyleForTheme(t);
applyTheme(t, nextStyle);
applyTheme(t, nextStyle, { persist: false });
setThemeState(t);
setThemeStyleState(nextStyle);
void apply(() => app.SetDesktopAppearance(t, nextStyle));
}}
onThemeStyle={(style) => {
const nextTheme = themeForStyle(style);
applyTheme(nextTheme, style);
applyTheme(nextTheme, style, { persist: false });
setThemeState(nextTheme);
setThemeStyleState(style);
void apply(() => app.SetDesktopAppearance(nextTheme, style));
}}
/>
)}
@@ -128,6 +144,8 @@ function settingsTabLabel(id: SettingsTab, t: ReturnType<typeof useT>): string {
switch (id) {
case "models":
return t("settings.tab.models");
case "general":
return t("settings.tab.general");
case "providers":
return t("settings.tab.providers");
case "network":
@@ -149,6 +167,8 @@ function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType<typeof
switch (id) {
case "models":
return toRef(s.defaultModel, s) || t("common.none");
case "general":
return closeBehaviorLabel(normalizeCloseBehavior(s.closeBehavior), t);
case "providers":
return t("settings.providerCount", { n: s.providers.length });
case "network":
@@ -205,6 +225,94 @@ function normalizeNetworkView(network: NetworkView): NetworkView {
return { ...network, proxyMode: normalizeProxyMode(network.proxyMode) };
}
function normalizeSettingsView(view: SettingsView | null | undefined): SettingsView | null {
if (!view) return null;
const permissions = view.permissions ?? { mode: "ask", allow: [], ask: [], deny: [] };
const sandbox = view.sandbox ?? { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [] };
const network = view.network ?? {
proxyMode: "auto",
proxyUrl: "",
noProxy: "",
proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
};
const agent = view.agent ?? { temperature: 0, maxSteps: 0, systemPrompt: "" };
return {
...view,
providers: asArray(view.providers).map((p) => ({ ...p, models: asArray(p.models) })),
providerKinds: asArray(view.providerKinds),
permissions: {
...permissions,
allow: asArray(permissions.allow),
ask: asArray(permissions.ask),
deny: asArray(permissions.deny),
},
sandbox: {
...sandbox,
allowWrite: asArray(sandbox.allowWrite),
},
network: {
...network,
proxy: network.proxy ?? { type: "socks5", server: "", port: 0, username: "", password: "" },
},
agent,
desktopLanguage: normalizeLangPref(view.desktopLanguage),
desktopTheme: normalizeThemePreference(view.desktopTheme),
desktopThemeStyle: normalizeThemeStyleForTheme(view.desktopThemeStyle, normalizeThemePreference(view.desktopTheme)),
closeBehavior: normalizeCloseBehavior(view.closeBehavior),
};
}
type CloseBehavior = "background" | "quit";
function normalizeCloseBehavior(mode: string | undefined): CloseBehavior {
return mode === "quit" ? "quit" : "background";
}
function closeBehaviorLabel(mode: CloseBehavior, t: ReturnType<typeof useT>): string {
return mode === "quit" ? t("settings.closeBehavior.quit") : t("settings.closeBehavior.background");
}
function GeneralSection({ s, busy, apply, platform }: SectionProps & { platform: string }) {
const { t, setPref } = useI18n();
const closeBehavior = normalizeCloseBehavior(s.closeBehavior);
const languagePref = normalizeLangPref(s.desktopLanguage);
const closeBehaviorHint =
platform === "darwin" ? t("settings.closeBehaviorHintMac") : t("settings.closeBehaviorHintDesktop");
const setLanguage = (next: LangPref) => {
setPref(next);
void apply(() => app.SetDesktopLanguage(next));
};
return (
<section className="mem-section">
<div className="mem-section__title">{t("settings.tab.general")}</div>
<div className="set-row">
<label className="set-label">{t("settings.language")}</label>
<select className="mem-select set-grow" value={languagePref} onChange={(e) => setLanguage(e.target.value as LangPref)}>
<option value="">{t("settings.langAuto")}</option>
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</div>
<div className="set-row">
<label className="set-label">{t("settings.closeBehavior")}</label>
<div className="set-seg">
{(["background", "quit"] as const).map((mode) => (
<button
key={mode}
className={`set-seg__btn${closeBehavior === mode ? " set-seg__btn--on" : ""}`}
disabled={busy}
onClick={() => void apply(() => app.SetCloseBehavior(mode))}
>
{closeBehaviorLabel(mode, t)}
</button>
))}
</div>
</div>
<div className="mem-hint set-hint">{closeBehaviorHint}</div>
</section>
);
}
function NetworkSection({ s, busy, apply }: SectionProps) {
const t = useT();
const savedNetwork = normalizeNetworkView(s.network);
@@ -453,15 +561,22 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
<button className="btn btn--small" disabled={busy} onClick={() => setEditing(p.name)}>
{t("common.edit")}
</button>
<Tooltip label={defaultProvider === p.name ? t("settings.cantDeleteDefault") : t("settings.deleteProvider")}>
<button
className="btn btn--small"
disabled={busy || defaultProvider === p.name}
onClick={() => void apply(() => app.DeleteProvider(p.name))}
>
{t("common.delete")}
</button>
</Tooltip>
{defaultProvider === p.name ? (
<Tooltip label={t("settings.cantDeleteDefault")}>
<button className="btn btn--small" disabled>
{t("common.delete")}
</button>
</Tooltip>
) : (
<InlineConfirmButton
label={t("common.delete")}
confirmLabel={t("settings.confirmDeleteProvider")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={() => apply(() => app.DeleteProvider(p.name))}
/>
)}
</div>
<div className="prov-card__meta">
<span>{p.kind}</span>
@@ -768,7 +883,7 @@ function AppearanceSection({
onTheme: (t: Theme) => void;
onThemeStyle: (style: ThemeStyle) => void;
}) {
const { t, pref, setPref } = useI18n();
const t = useT();
const themeOptions: Theme[] = ["auto", "light", "dark"];
return (
<section className="mem-section">
@@ -802,14 +917,6 @@ function AppearanceSection({
))}
</div>
</div>
<div className="set-row">
<label className="set-label">{t("settings.language")}</label>
<select className="mem-select set-grow" value={pref} onChange={(e) => setPref(e.target.value as "" | "en" | "zh")}>
<option value="">{t("settings.langAuto")}</option>
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</div>
</section>
);
}
+59 -124
View File
@@ -1,24 +1,27 @@
import { useEffect, useState } from "react";
import { Coins, Cpu, Wallet } from "lucide-react";
import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { useState } from "react";
import { Tooltip } from "./Tooltip";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import type { BalanceInfo, ContextInfo, EffortInfo, JobView, Meta, Mode, WireUsage } from "../lib/types";
import { useI18n } from "../lib/i18n";
import type { BalanceInfo, ContextInfo, JobView, Mode, WireUsage } from "../lib/types";
// JobsChip is the status-bar background-jobs indicator: a count that opens an
// upward popover listing the running jobs (id · label · status), mirroring the
// ModelSwitcher's click-to-open pattern. It renders nothing when there are no
// jobs, so the caller guards on jobs.length first.
// ModelSwitcher's click-to-open pattern. With no jobs it still reserves a stable
// "jobs 0" slot so the IDE-style status order does not jump.
function JobsChip({ jobs }: { jobs: JobView[] }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
if (jobs.length === 0) {
return (
<span className="statusbar__item">
{t("status.jobsCount", { n: 0 })}
</span>
);
}
return (
<div className="statusbar__jobswrap">
<Tooltip label={t("status.jobsTitle")}>
<button className="statusbar__jobs" onClick={() => setOpen((v) => !v)}>
<Cpu size={11} />
{t("status.jobs", { n: jobs.length })}
<button className="statusbar__item statusbar__jobs" onClick={() => setOpen((v) => !v)}>
{t("status.jobsCount", { n: jobs.length })}
</button>
</Tooltip>
{open && (
@@ -40,162 +43,94 @@ function JobsChip({ jobs }: { jobs: JobView[] }) {
);
}
function formatRate(hit: number, denom: number): string | null {
if (denom <= 0) return null;
return ((hit / denom) * 100).toFixed(2);
}
// nowRate is the SINGLE-TURN prompt cache-hit % (latest turn) — the higher,
// steeper number on a non-compacting DeepSeek session. null when nothing yet.
function nowRate(u?: WireUsage): number | null {
function nowRate(u?: WireUsage): string | null {
if (!u) return null;
let denom = u.cacheHitTokens + u.cacheMissTokens;
if (denom === 0) denom = u.promptTokens;
if (denom <= 0) return null;
return Math.round((u.cacheHitTokens / denom) * 100);
return formatRate(u.cacheHitTokens, denom);
}
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
// drag the average down); it overtakes only when compaction craters single turns.
function avgRate(u?: WireUsage): number | null {
function avgRate(u?: WireUsage): string | null {
if (!u) return null;
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
if (denom <= 0) return null;
return Math.round((u.sessionCacheHitTokens / denom) * 100);
return formatRate(u.sessionCacheHitTokens, denom);
}
function fmtTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
function currencySymbol(currency?: string): string {
const value = (currency || "¥").trim();
if (/^(cny|rmb|yuan)$/i.test(value)) return "¥";
if (/^(usd|dollar)$/i.test(value)) return "$";
return value || "¥";
}
function fmtElapsed(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
// useTick re-renders once a second while `on`, so the elapsed clock advances.
function useTick(on: boolean): number {
const [, setN] = useState(0);
useEffect(() => {
if (!on) return;
const id = setInterval(() => setN((n) => n + 1), 1000);
return () => clearInterval(id);
}, [on]);
return Date.now();
function formatMoney(amount?: number, currency?: string): string {
const symbol = currencySymbol(currency);
if (typeof amount !== "number" || amount <= 0) return `${symbol}0.0000`;
return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`;
}
export function StatusBar({
meta,
context,
usage,
balance,
effort,
jobs,
running,
mode,
turnStartAt,
turnTokens,
cost,
retry,
onSwitchModel,
onSetEffort,
currency,
}: {
meta?: Meta;
context: ContextInfo;
usage?: WireUsage;
balance?: BalanceInfo;
effort?: EffortInfo;
jobs?: JobView[];
running: boolean;
mode: Mode;
turnStartAt: number;
turnTokens: number;
cost?: number;
retry?: { attempt: number; max: number };
onSwitchModel: (name: string) => void;
onSetEffort: (level: string) => void;
currency?: string;
}) {
const { t, locale } = useI18n();
const now = useTick(running);
const { t } = useI18n();
const pct = context.window ? Math.min(100, Math.round((context.used / context.window) * 100)) : null;
const compactPct = context.compactRatio ? Math.round(context.compactRatio * 100) : null;
const nowPct = nowRate(usage);
const avgPct = avgRate(usage);
// While a turn runs, the status line shows live activity (word · elapsed ·
// tokens) in place of the static context gauge.
let activity: string | null = null;
if (retry) {
activity = t("status.retrying", { attempt: retry.attempt, max: retry.max });
} else if (running && turnStartAt) {
const elapsedMs = Math.max(0, now - turnStartAt);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
activity = `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
}
const jobsList = jobs ?? [];
const costLabel = formatMoney(cost, currency);
return (
<div className="statusbar">
<span className={`statusbar__dot ${running ? "statusbar__dot--busy" : ""}`} />
<ModelSwitcher label={meta?.label ?? t("status.connecting")} onPick={onSwitchModel} />
{effort?.supported && (
<>
<span className="statusbar__sep">·</span>
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
</>
)}
{activity ? (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__activity">{activity}</span>
</>
) : (
pct !== null && (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__ctx">{t("status.ctx", { pct })}</span>
</>
)
)}
{nowPct !== null && (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__cache">{t("status.cache", { pct: nowPct })}</span>
</>
)}
{avgPct !== null && (
<>
<span className="statusbar__sep">·</span>
<span className="statusbar__cache">{t("status.cacheAvg", { pct: avgPct })}</span>
</>
)}
{jobs && jobs.length > 0 && (
<>
<span className="statusbar__sep">·</span>
<JobsChip jobs={jobs} />
</>
)}
{typeof cost === "number" && cost > 0 && (
<>
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.spendTitle")}>
<span className="statusbar__balance statusbar__cost">
<Coins size={11} />
{`¥${cost < 1 ? cost.toFixed(4) : cost.toFixed(2)}`}
</span>
</Tooltip>
</>
)}
{balance?.available && balance.display && (
<>
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.balanceTitle")}>
<span className="statusbar__balance">
<Wallet size={11} />
{balance.display}
</span>
</Tooltip>
</>
)}
<span className="statusbar__item statusbar__ctx">{pct !== null ? t("status.ctx", { pct }) : t("status.ctxUnknown")}</span>
<span className="statusbar__sep">·</span>
<span className="statusbar__item statusbar__compact">{compactPct !== null ? t("status.compact", { pct: compactPct }) : t("status.compactUnknown")}</span>
<span className="statusbar__sep">·</span>
<span className="statusbar__item statusbar__cache">{t("status.cache", { pct: nowPct ?? "-" })}</span>
<span className="statusbar__sep">·</span>
<span className="statusbar__item statusbar__avg">{t("status.cacheAvg", { pct: avgPct ?? "-" })}</span>
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.spendTitle")}>
<span className="statusbar__item statusbar__cost">
{t("status.cost", { amount: costLabel })}
</span>
</Tooltip>
<span className="statusbar__sep">·</span>
<JobsChip jobs={jobsList} />
<span className="statusbar__sep">·</span>
<Tooltip label={t("status.balanceTitle")}>
<span className="statusbar__item statusbar__balance">
{t("status.balance", { amount: balance?.available && balance.display ? balance.display : "-" })}
</span>
</Tooltip>
<span className="statusbar__spacer" />
{mode === "plan" && <span className="statusbar__plan">{t("status.plan")}</span>}
</div>
+268
View File
@@ -0,0 +1,268 @@
// TabBar renders the browser-like workspace tab strip. Each tab represents one
// open project/global topic, so switching tabs switches the active conversation.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
import { FileText, Plus, X } from "lucide-react";
import type { TabMeta } from "../lib/types";
import { projectColorValue } from "../lib/projectColors";
import { useT } from "../lib/i18n";
import { Tooltip } from "./Tooltip";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
interface TabBarProps {
tabs: TabMeta[];
activeTabId?: string;
onTabChange: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onTabsClose: (tabIds: string[], nextActiveTabId?: string) => void;
onTabsReorder: (tabIds: string[]) => void;
onNewTab: () => void;
revealActiveSignal?: number;
}
type DropSide = "before" | "after";
function tabDisplayTitle(tab: TabMeta): string {
if (tab.tabType === "file" || tab.scope === "file") return tab.topicTitle?.trim() || tab.filePath?.split("/").filter(Boolean).pop() || "File";
const title = tab.topicTitle?.trim();
if (tab.scope === "global") return title || "Global";
return title || "Untitled";
}
function tabFullTitle(tab: TabMeta): string {
if (tab.tabType === "file" || tab.scope === "file") return tab.filePath || tabDisplayTitle(tab);
if (tab.scope === "global") {
const title = tabDisplayTitle(tab);
const workspaceName = tab.workspaceName?.trim() || "Global";
return title === workspaceName ? workspaceName : `${workspaceName} / ${title}`;
}
const workspaceName = tab.workspaceName?.trim() || "Project";
return `${workspaceName} / ${tabDisplayTitle(tab)}`;
}
function tabMode(tab: TabMeta): "normal" | "plan" | "yolo" {
return tab.mode === "plan" || tab.mode === "yolo" ? tab.mode : "normal";
}
function projectAccentStyle(color?: string): CSSProperties | undefined {
const value = projectColorValue(color);
if (!value) return undefined;
return { "--project-accent": value } as CSSProperties;
}
export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, revealActiveSignal = 0 }: TabBarProps) {
const t = useT();
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{ id: string; side: DropSide } | null>(null);
const [menuTabId, setMenuTabId] = useState<string | null>(null);
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const suppressClickRef = useRef(false);
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const backendActiveTabId = tabs.find((tab) => tab.active)?.id;
const activeTabIdExists = Boolean(activeTabId && tabs.some((tab) => tab.id === activeTabId));
const resolvedActiveTabId = activeTabIdExists ? activeTabId : backendActiveTabId;
const tabOrderKey = tabs.map((tab) => tab.id).join("\u0000");
useEffect(() => {
if (!resolvedActiveTabId) return;
const frame = window.requestAnimationFrame(() => {
tabRefs.current.get(resolvedActiveTabId)?.scrollIntoView({
block: "nearest",
inline: "nearest",
});
});
return () => window.cancelAnimationFrame(frame);
}, [backendActiveTabId, resolvedActiveTabId, revealActiveSignal, tabOrderKey]);
const handleClose = (tabId: string) => {
onTabClose(tabId);
};
const clearDragState = () => {
setDraggingTabId(null);
setDropTarget(null);
};
const dropSideForEvent = (event: DragEvent<HTMLButtonElement>): DropSide => {
const rect = event.currentTarget.getBoundingClientRect();
return event.clientX > rect.left + rect.width / 2 ? "after" : "before";
};
const reorderTabIds = (draggedId: string, targetId: string, side: DropSide): string[] => {
const ids = tabs.map((tab) => tab.id);
const from = ids.indexOf(draggedId);
const target = ids.indexOf(targetId);
if (from < 0 || target < 0 || draggedId === targetId) return ids;
const next = ids.filter((id) => id !== draggedId);
const targetAfterRemoval = next.indexOf(targetId);
const insertAt = side === "after" ? targetAfterRemoval + 1 : targetAfterRemoval;
next.splice(insertAt, 0, draggedId);
return next;
};
const handleDragStart = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
setDraggingTabId(tabId);
setDropTarget(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tabId);
};
const handleDragOver = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
if (!draggingTabId || draggingTabId === tabId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDropTarget({ id: tabId, side: dropSideForEvent(event) });
};
const handleDrop = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
event.preventDefault();
const draggedId = draggingTabId || event.dataTransfer.getData("text/plain");
const side = dropTarget?.id === tabId ? dropTarget.side : dropSideForEvent(event);
clearDragState();
if (!draggedId || draggedId === tabId) return;
const next = reorderTabIds(draggedId, tabId, side);
if (next.join("\u0000") !== tabs.map((tab) => tab.id).join("\u0000")) {
suppressClickRef.current = true;
onTabsReorder(next);
}
};
const handleTabClick = (tabId: string) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
onTabChange(tabId);
};
const openTabMenu = (event: ReactMouseEvent<HTMLButtonElement> | ReactKeyboardEvent<HTMLButtonElement>, tabId: string) => {
event.preventDefault();
event.stopPropagation();
setMenuTabId(tabId);
setMenuPoint(contextMenuPointFromEvent(event));
};
const closeTabMenu = () => {
setMenuTabId(null);
setMenuPoint(null);
};
const closeTabsFromMenu = (tabIds: string[], nextActiveTabId?: string) => {
closeTabMenu();
onTabsClose(tabIds, nextActiveTabId);
};
const menuTabIndex = menuTabId ? tabs.findIndex((tab) => tab.id === menuTabId) : -1;
const tabMenuItems: ContextMenuItem[] = menuTabId && menuTabIndex >= 0
? [
{
key: "close-current",
label: t("tabBar.closeTab"),
disabled: tabs.length <= 1,
onSelect: () => closeTabsFromMenu([menuTabId]),
},
{
key: "close-other",
label: t("tabBar.closeOtherTabs"),
disabled: tabs.length <= 1,
onSelect: () => closeTabsFromMenu(tabs.filter((tab) => tab.id !== menuTabId).map((tab) => tab.id), menuTabId),
},
{
key: "close-right",
label: t("tabBar.closeTabsToRight"),
disabled: menuTabIndex >= tabs.length - 1,
onSelect: () => {
const rightTabIds = tabs.slice(menuTabIndex + 1).map((tab) => tab.id);
const nextActiveTabId = resolvedActiveTabId && rightTabIds.includes(resolvedActiveTabId) ? menuTabId : undefined;
closeTabsFromMenu(rightTabIds, nextActiveTabId);
},
},
]
: [];
return (
<div className="tabbar">
<div className="tabbar__tabs">
{tabs.map((tab) => {
const displayTitle = tabDisplayTitle(tab);
const fullTitle = tabFullTitle(tab);
const mode = tabMode(tab);
const stateTitle = [
tab.running ? "Running" : "",
mode === "yolo" ? "YOLO" : mode === "plan" ? "Plan" : "",
].filter(Boolean).join(" · ");
const annotatedTitle = stateTitle ? `${stateTitle} · ${fullTitle}` : fullTitle;
return (
<button
key={tab.id}
ref={(node) => {
if (node) {
tabRefs.current.set(tab.id, node);
} else {
tabRefs.current.delete(tab.id);
}
}}
draggable
className={[
"tabbar__tab",
tab.id === resolvedActiveTabId ? "tabbar__tab--active" : "",
mode === "yolo" ? "tabbar__tab--yolo" : "",
draggingTabId === tab.id ? "tabbar__tab--dragging" : "",
dropTarget?.id === tab.id ? `tabbar__tab--drop-${dropTarget.side}` : "",
].filter(Boolean).join(" ")}
title={annotatedTitle}
aria-label={annotatedTitle}
style={projectAccentStyle(tab.projectColor)}
onClick={() => handleTabClick(tab.id)}
onContextMenu={(event) => openTabMenu(event, tab.id)}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openTabMenu(event, tab.id);
}
}}
onDragStart={(event) => handleDragStart(event, tab.id)}
onDragOver={(event) => handleDragOver(event, tab.id)}
onDrop={(event) => handleDrop(event, tab.id)}
onDragEnd={clearDragState}
>
{tab.tabType === "file" || tab.scope === "file" ? (
<FileText size={12} className="tabbar__file-icon" />
) : (
<span
className={[
"tabbar__status",
tab.running ? "tabbar__status--running" : "",
].filter(Boolean).join(" ")}
/>
)}
<span className="tabbar__tab-label">{displayTitle}</span>
{mode === "yolo" && <span className="tabbar__mode-badge tabbar__mode-badge--yolo">yolo</span>}
<span
className="tabbar__tab-close"
onClick={(e) => {
e.stopPropagation();
handleClose(tab.id);
}}
>
<X size={10} />
</span>
</button>
);
})}
</div>
<Tooltip label={t("tabBar.newSession")}>
<button className="tabbar__new" type="button" aria-label={t("tabBar.newSession")} onClick={onNewTab}>
<Plus size={13} />
</button>
</Tooltip>
<ContextMenu
open={Boolean(menuTabId)}
point={menuPoint}
items={tabMenuItems}
minWidth={170}
ariaLabel={t("tabBar.tabActions")}
onClose={closeTabMenu}
/>
</div>
);
}
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, RefreshCw, X } from "lucide-react";
import { useT } from "../lib/i18n";
import type { Todo } from "../lib/tools";
import { Tooltip } from "./Tooltip";
@@ -11,7 +11,7 @@ import { Tooltip } from "./Tooltip";
// shows the current item so the footer stays compact during a long run. The ✕
// dismisses it (onDismiss) when the user abandons the task; a fresh todo_write
// brings it back.
export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () => void }) {
export function TodoPanel({ todos, stale, onDismiss }: { todos: Todo[]; stale?: boolean; onDismiss: () => void }) {
const t = useT();
const [open, setOpen] = useState(true);
if (todos.length === 0) return null;
@@ -28,6 +28,12 @@ export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () =
<span className="todobar__count">
{done}/{todos.length}
</span>
{stale && (
<span className="todobar__stale">
<RefreshCw size={11} />
{t("todo.stale")}
</span>
)}
{!open && current && (
<span className="todobar__current">{current.activeForm || current.content}</span>
)}
+202 -11
View File
@@ -1,11 +1,25 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { type CSSProperties, type MouseEvent as ReactMouseEvent, useEffect, useMemo, useRef, useState } from "react";
import type { Item, LiveStream } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
import { useT } from "../lib/i18n";
import { AssistantMessage, UserMessage } from "./Message";
import { ToolCard } from "./ToolCard";
import { Welcome } from "./Welcome";
type ToolItem = Extract<Item, { kind: "tool" }>;
type QuestionAnchor = { id: string; text: string; turn: number };
const QUESTION_NAV_MIN_COUNT = 2;
function questionAnchorId(id: string): string {
return `question-anchor-${id}`;
}
function compactQuestionText(text: string): string {
const cleaned = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]").replace(/\s+/g, " ").trim();
if (cleaned.length <= 80) return cleaned;
return cleaned.slice(0, 80);
}
function scrollVersion(items: Item[]): string {
return items
@@ -45,12 +59,20 @@ export function Transcript({
footerHeight = 0,
onPrompt,
onRewind,
checkpoints = [],
actionPending = false,
rewindDisabled = false,
questionNavigator = true,
}: {
items: Item[];
live?: LiveStream;
footerHeight?: number;
onPrompt: (text: string) => void;
onRewind?: (turn: number, scope: string) => void;
checkpoints?: CheckpointMeta[];
actionPending?: boolean;
rewindDisabled?: boolean;
questionNavigator?: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// stick tracks whether the view is pinned to the bottom; once the user scrolls
@@ -60,6 +82,18 @@ export function Transcript({
const lastClientHeight = useRef<number | null>(null);
const lastFooterHeight = useRef<number | null>(null);
const questions = useMemo<QuestionAnchor[]>(() => {
const anchors: QuestionAnchor[] = [];
let turn = 0;
for (const it of items) {
if (it.kind !== "user") continue;
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn });
turn += 1;
}
return anchors;
}, [items]);
const showQuestionNav = questionNavigator && questions.length >= QUESTION_NAV_MIN_COUNT;
const onScroll = () => {
const el = scrollRef.current;
if (el) stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
@@ -148,15 +182,37 @@ export function Transcript({
// Each user message's turn = its ordinal among user messages, so a rewind
// targets the matching checkpoint.
const userTurn = new Map<string, number>();
let nt = 0;
for (const it of items) {
if (it.kind === "user") userTurn.set(it.id, nt++);
}
const userTurn = useMemo(() => new Map(questions.map((question) => [question.id, question.turn])), [questions]);
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
const jumpToQuestion = (question: QuestionAnchor) => {
const el = scrollRef.current;
const node = document.getElementById(questionAnchorId(question.id));
if (!el || !node) return;
stick.current = false;
if (resizeFrame.current !== null) {
cancelAnimationFrame(resizeFrame.current);
resizeFrame.current = null;
}
const scrollerRect = el.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const top = el.scrollTop + nodeRect.top - scrollerRect.top - 12;
el.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
};
const empty = items.length === 0;
return (
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
{items.length === 0 && <Welcome onPrompt={onPrompt} />}
<div
className={`transcript${empty ? " transcript--empty" : ""}`}
ref={scrollRef}
onScroll={onScroll}
>
{empty && <Welcome onPrompt={onPrompt} />}
{!empty && showQuestionNav && (
<QuestionJumpBar questions={questions} onJump={jumpToQuestion} />
)}
{items.map((it) => {
switch (it.kind) {
@@ -167,9 +223,13 @@ export function Transcript({
key={it.id}
text={it.text}
turn={tn}
open={tn != null && openTurn === tn}
onToggle={() => setOpenTurn((cur) => (cur === tn ? null : (tn ?? null)))}
onRewind={(turn, scope) => {
anchorId={questionAnchorId(it.id)}
open={tn != null && openTurn === tn}
onToggle={() => setOpenTurn((cur) => (cur === tn ? null : (tn ?? null)))}
checkpoint={tn != null ? checkpointsByTurn.get(tn) : undefined}
actionPending={actionPending}
rewindDisabled={rewindDisabled}
onRewind={(turn, scope) => {
onRewind?.(turn, scope);
setOpenTurn(null);
}}
@@ -207,6 +267,137 @@ export function Transcript({
);
}
function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; onJump: (question: QuestionAnchor) => void }) {
const t = useT();
const [hovered, setHovered] = useState<number | null>(null);
const [active, setActive] = useState<number | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const previewTop = useRef(0);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
if (questions.length === 0) return;
setActive((cur) => {
if (cur !== null && questions.some((question) => question.turn === cur)) return cur;
return questions[questions.length - 1]?.turn ?? null;
});
}, [questions]);
useEffect(() => {
if (active === null) return;
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [active]);
const hoverIdx = hovered !== null ? questions.findIndex((question) => question.turn === hovered) : -1;
const hoveredQuestion = hovered !== null ? questions.find((question) => question.turn === hovered) : undefined;
const closestQuestionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
const el = barRef.current;
if (!el) return null;
const markers = el.querySelectorAll<HTMLElement>(".jump-item");
const barRect = el.getBoundingClientRect();
let closest = -1;
let closestDist = Infinity;
let closestY = 0;
markers.forEach((item, index) => {
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dist = Math.abs(clientY - midY);
if (dist < closestDist) {
closestDist = dist;
closest = index;
closestY = midY - barRect.top;
}
});
const question = questions[closest];
if (!question) return null;
return { question, previewY: closestY };
};
const onMove = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
setShowPreview(true);
};
const scrollTo = (question: QuestionAnchor) => {
setActive(question.turn);
onJump(question);
};
const onRailMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
e.preventDefault();
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
setShowPreview(true);
scrollTo(closest.question);
};
const onItemMouseDown = (e: ReactMouseEvent<HTMLButtonElement>, question: QuestionAnchor) => {
e.preventDefault();
scrollTo(question);
};
const dotProps = (
idx: number,
turn: number,
): { style: CSSProperties; "data-d"?: string } => {
const isActive = active === turn;
if (hoverIdx < 0) {
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
}
const d = Math.abs(idx - hoverIdx);
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
return {
style: { width, transitionDelay: `${d * 20}ms`, background },
"data-d": d <= 2 ? String(d) : undefined,
};
};
return (
<nav
className="jump-bar"
ref={barRef}
aria-label={t("questionNav.label")}
onMouseMove={onMove}
onMouseLeave={() => {
setHovered(null);
setShowPreview(false);
}}
>
<div className="jump-scroll" onMouseDown={onRailMouseDown} onClick={onRailMouseDown}>
{questions.map((question, index) => (
<button
className="jump-item"
key={question.id}
type="button"
data-turn={question.turn}
aria-label={t("questionNav.jump", { n: question.turn + 1 })}
onMouseDown={(e) => onItemMouseDown(e, question)}
onClick={(e) => {
e.stopPropagation();
if (e.detail === 0) scrollTo(question);
}}
>
<span className="jump-dot" {...dotProps(index, question.turn)} />
</button>
))}
</div>
{showPreview && hoveredQuestion && (
<div className="jump-preview" style={{ top: previewTop.current }} role="tooltip">
<span className="jump-text">{hoveredQuestion.text}</span>
</div>
)}
</nav>
);
}
type CompactionItem = Extract<Item, { kind: "compaction" }>;
// CompactionCard marks a context-compaction boundary in the transcript. While
+248 -136
View File
@@ -9,16 +9,14 @@ import type {
import {
ChevronDown,
ChevronRight,
Columns2,
FileText,
Folder,
FolderTree,
FolderX,
GitBranch,
Maximize2,
MessageSquarePlus,
Minus,
Minimize2,
PanelRightClose,
Plus,
RefreshCw,
Search,
X,
@@ -29,16 +27,22 @@ import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import type { DirEntry, FilePreview, WorkspaceChangeView, WorkspaceChangesView } from "../lib/types";
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
import { CodeViewer } from "./CodeViewer";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
import { Markdown } from "./Markdown";
import { Tooltip } from "./Tooltip";
import { AnchoredPopover } from "./AnchoredPopover";
const WORKSPACE_TREE_MIN_WIDTH = 220;
const WORKSPACE_TREE_DEFAULT_WIDTH = WORKSPACE_TREE_MIN_WIDTH;
const WORKSPACE_TREE_MAX_WIDTH = 420;
const WORKSPACE_PREVIEW_MIN_WIDTH = 420;
const WORKSPACE_TREE_MIN_WIDTH = 208;
const WORKSPACE_TREE_DEFAULT_WIDTH = 240;
const WORKSPACE_TREE_MAX_WIDTH = 340;
const WORKSPACE_PREVIEW_MIN_WIDTH = 360;
const WORKSPACE_PREVIEW_TARGET_WIDTH = 480;
const WORKSPACE_DUAL_PANEL_MIN_WIDTH = WORKSPACE_TREE_MIN_WIDTH + WORKSPACE_PREVIEW_MIN_WIDTH;
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 92;
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 48;
const WORKSPACE_MAX_PREVIEW_TABS = 5;
function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
const maxForPanel =
@@ -155,7 +159,10 @@ export function WorkspacePanel({
onToggleMaximized,
onPreviewModeChange,
onAddToChat,
changesRefreshKey,
onRequestPanelWidth,
refreshKey,
initialViewMode = "files",
showViewTabs = true,
}: {
open: boolean;
cwd?: string;
@@ -165,7 +172,10 @@ export function WorkspacePanel({
onToggleMaximized: () => void;
onPreviewModeChange?: (active: boolean) => void;
onAddToChat?: (text: string) => void;
changesRefreshKey?: number;
onRequestPanelWidth?: (width: number) => void;
refreshKey?: number;
initialViewMode?: "files" | "changed";
showViewTabs?: boolean;
}) {
const t = useT();
const panelRef = useRef<HTMLElement>(null);
@@ -177,16 +187,24 @@ export function WorkspacePanel({
const [openTabs, setOpenTabs] = useState<string[]>([]);
const [preview, setPreview] = useState<FilePreview | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [viewMode, setViewMode] = useState<"files" | "changed">("files");
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
const [changes, setChanges] = useState<WorkspaceChangesView | null>(null);
const [loadingChanges, setLoadingChanges] = useState(false);
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; path: string } | null>(null);
const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
const changesRequestRef = useRef(0);
const [filter, setFilter] = useState("");
const [treeVisible, setTreeVisible] = useState(true);
const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth);
const [treeResizing, setTreeResizing] = useState(false);
const [recentOpen, setRecentOpen] = useState(false);
const recentAnchorRef = useRef<HTMLButtonElement>(null);
const openDirsRef = useRef(openDirs);
useEffect(() => {
openDirsRef.current = openDirs;
}, [openDirs]);
const loadDir = useCallback(async (dir: string) => {
const entries = await app.ListDir(dir).catch(() => []);
@@ -211,16 +229,17 @@ export function WorkspacePanel({
const selectFile = useCallback(
(path: string) => {
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
setSelectedPath(path);
setFilter("");
setOpenTabs((tabs) => (tabs.includes(path) ? tabs : [...tabs, path]));
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
const dirs = parentDirs(path);
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
},
[entriesByDir, loadDir],
[entriesByDir, loadDir, onRequestPanelWidth],
);
useEffect(() => {
@@ -235,13 +254,26 @@ export function WorkspacePanel({
setTreeMenu(null);
setFilter("");
setTreeVisible(true);
setViewMode(initialViewMode);
void loadDir("");
}, [cwd, loadDir, open]);
}, [cwd, initialViewMode, loadDir, open]);
useEffect(() => {
if (!open) return;
setViewMode(initialViewMode);
if (initialViewMode === "changed") void loadChanges();
}, [initialViewMode, loadChanges, open]);
useEffect(() => {
if (!open) return;
void loadChanges();
}, [changesRefreshKey, cwd, loadChanges, open]);
}, [cwd, loadChanges, open]);
useEffect(() => {
if (!open || !refreshKey) return;
void loadChanges();
openDirsRef.current.forEach((dir) => void loadDir(dir));
}, [loadChanges, loadDir, open, refreshKey]);
useEffect(() => {
if (!selectionMenu && !treeMenu) return;
@@ -262,6 +294,19 @@ export function WorkspacePanel({
};
}, [selectionMenu, treeMenu]);
const refreshWorkspaceList = useCallback(() => {
setTreeBlankMenuPoint(null);
setSelectionMenu(null);
setTreeMenu(null);
if (viewMode === "changed") {
void loadChanges();
return;
}
const dirs = Array.from(openDirsRef.current);
setEntriesByDir({});
dirs.forEach((dir) => void loadDir(dir));
}, [loadChanges, loadDir, viewMode]);
const refreshSelected = useCallback(() => {
if (!selectedPath) return;
let live = true;
@@ -312,16 +357,6 @@ export function WorkspacePanel({
[entriesByDir, loadDir],
);
const openPickerTab = () => {
setSelectedPath(null);
setPreview(null);
setFilter("");
setSelectionMenu(null);
setTreeMenu(null);
setTreeVisible(true);
requestAnimationFrame(() => filterRef.current?.focus());
};
const closeTab = (path: string) => {
setOpenTabs((tabs) => {
const next = tabs.filter((tab) => tab !== path);
@@ -334,6 +369,7 @@ export function WorkspacePanel({
}
setSelectionMenu(null);
setTreeMenu(null);
setRecentOpen(false);
}
return next;
});
@@ -341,6 +377,9 @@ export function WorkspacePanel({
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
const pathParts = selectedPath?.split("/").filter(Boolean) ?? [];
const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
const currentFileDir = selectedPath ? parentPath(selectedPath) : "";
const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
const flattened = useMemo(() => {
const rows: { path: string; entry: DirEntry }[] = [];
for (const [dir, entries] of Object.entries(entriesByDir)) {
@@ -361,14 +400,25 @@ export function WorkspacePanel({
if (!q) return rows;
return rows.filter((row) => `${row.path} ${row.oldPath ?? ""} ${row.gitStatus ?? ""}`.toLowerCase().includes(q));
}, [changes?.files, filter]);
const searchPlaceholder = viewMode === "changed" ? t("workspace.filterChanges") : t("workspace.filter");
const effectiveTreeWidth = useMemo(() => clampWorkspaceTreeWidth(treeWidth, panelWidth), [panelWidth, treeWidth]);
const previewVisible = openTabs.length > 0 || selectedPath !== null;
const selectedFileVisible = selectedPath !== null;
const compactTreeSplit =
treeVisible && selectedFileVisible && panelWidth !== undefined && panelWidth < WORKSPACE_DUAL_PANEL_MIN_WIDTH;
const actualTreeVisible = treeVisible;
const previewModeActive = open && previewVisible;
const embeddedDockMode = !showViewTabs;
const showFileTools = showViewTabs || previewVisible;
const panelStyle = useMemo(
() => ({ "--workspace-tree-width": `${effectiveTreeWidth}px` }) as CSSProperties,
[effectiveTreeWidth],
() =>
({
"--workspace-tree-width": `${effectiveTreeWidth}px`,
"--workspace-preview-min-width": compactTreeSplit ? "0px" : `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
}) as CSSProperties,
[compactTreeSplit, effectiveTreeWidth],
);
useEffect(() => {
@@ -405,7 +455,7 @@ export function WorkspacePanel({
setTreeResizing(true);
let nextWidth = effectiveTreeWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = clampWorkspaceTreeWidth(rect.right - moveEvent.clientX, rect.width);
nextWidth = clampWorkspaceTreeWidth(moveEvent.clientX - rect.left, rect.width);
setTreeWidth(nextWidth);
};
const onDone = () => {
@@ -431,7 +481,7 @@ export function WorkspacePanel({
(event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowLeft" ? 16 : -16));
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowRight" ? 16 : -16));
} else if (event.key === "Home") {
event.preventDefault();
setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
@@ -474,10 +524,21 @@ export function WorkspacePanel({
const openTreeMenu = (event: ReactMouseEvent<HTMLElement>, path: string, isDir: boolean) => {
event.preventDefault();
event.stopPropagation();
setTreeBlankMenuPoint(null);
setSelectionMenu(null);
setTreeMenu({ x: event.clientX, y: event.clientY, path, isDir });
};
const openTreeBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null;
if (target?.closest(".workspace-tree__row,.workspace-change,button,input,textarea,select")) return;
event.preventDefault();
event.stopPropagation();
setSelectionMenu(null);
setTreeMenu(null);
setTreeBlankMenuPoint(contextMenuPointFromEvent(event));
};
const startTreeDrag = (event: ReactDragEvent<HTMLElement>, path: string, isDir: boolean) => {
const ref = formatWorkspaceReference(path, isDir);
event.dataTransfer.effectAllowed = "copy";
@@ -550,32 +611,31 @@ export function WorkspacePanel({
const isOpen = openDirs.has(path);
const active = selectedPath === path;
const row = (
<Tooltip key={path} label={path} fill>
<button
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{entry.isDir ? (
isOpen ? (
<ChevronDown size={13} className="workspace-tree__chev" />
) : (
<ChevronRight size={13} className="workspace-tree__chev" />
)
<button
key={path}
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{entry.isDir ? (
isOpen ? (
<ChevronDown size={13} className="workspace-tree__chev" />
) : (
<span className="workspace-tree__chev" />
)}
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<FileText size={14} className="workspace-tree__icon" />
)}
<span className="workspace-tree__name">{entry.name}</span>
</button>
</Tooltip>
<ChevronRight size={13} className="workspace-tree__chev" />
)
) : (
<span className="workspace-tree__chev" />
)}
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<FileText size={14} className="workspace-tree__icon" />
)}
<span className="workspace-tree__name">{entry.name}</span>
</button>
);
if (!entry.isDir || !isOpen) return [row];
return [row, ...renderRows(path, depth + 1)];
@@ -583,50 +643,42 @@ export function WorkspacePanel({
};
const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
const treeBlankMenuItems: ContextMenuItem[] = [
{
key: "refresh-tree",
icon: <RefreshCw size={13} />,
label: t(viewMode === "changed" ? "workspace.refreshChanges" : "workspace.refreshTree"),
onSelect: refreshWorkspaceList,
},
];
return (
<aside
ref={panelRef}
className={`workspace-panel${treeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${compactTreeSplit ? " workspace-panel--compact-split" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
aria-label={t("workspace.title")}
style={panelStyle}
>
{previewVisible && <section className="workspace-preview">
<header className="workspace-preview__head">
<div className="workspace-tabs">
{openTabs.map((tab) => (
<Tooltip key={tab} label={tab}>
<button
className={`workspace-tab${selectedPath === tab ? " workspace-tab--active" : ""}`}
onClick={() => setSelectedPath(tab)}
>
<FileText size={14} className="workspace-tab__icon" />
<span className="workspace-tab__name">{basename(tab)}</span>
<span
className="workspace-tab__close"
role="button"
tabIndex={0}
aria-label={t("workspace.closeTab")}
onClick={(e) => {
e.stopPropagation();
closeTab(tab);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
closeTab(tab);
}
}}
>
<X size={12} />
</span>
</button>
<div className="workspace-current-file" aria-label={t("workspace.currentFile")}>
<FileText size={15} className="workspace-current-file__icon" />
<div className="workspace-current-file__text">
<Tooltip label={selectedPath ?? undefined}>
<span className="workspace-current-file__name">{currentFileName}</span>
</Tooltip>
))}
<Tooltip label={t("workspace.newTab")}>
<button className="workspace-tab workspace-tab--new" onClick={openPickerTab}>
<Plus size={14} />
{currentFileDir && <span className="workspace-current-file__path">{currentFileDir}</span>}
</div>
<Tooltip label={t("workspace.recentFiles")}>
<button
ref={recentAnchorRef}
className={`workspace-current-file__recent${recentOpen ? " workspace-current-file__recent--open" : ""}`}
type="button"
aria-label={t("workspace.recentFiles")}
aria-expanded={recentOpen}
onClick={() => setRecentOpen((open) => !open)}
>
<ChevronDown size={13} />
</button>
</Tooltip>
</div>
@@ -637,20 +689,44 @@ export function WorkspacePanel({
{maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</Tooltip>
<Tooltip label={t("workspace.minimize")}>
<button className="workspace-iconbtn" onClick={onClose}>
<Minus size={15} />
</button>
</Tooltip>
<Tooltip label={treeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
onClick={() => setTreeVisible((value) => !value)}
>
{treeVisible ? <PanelRightClose size={15} /> : <Columns2 size={15} />}
</button>
</Tooltip>
{selectedPath && (
<Tooltip label={t("workspace.closePreview")}>
<button className="workspace-iconbtn" onClick={() => closeTab(selectedPath)}>
<X size={15} />
</button>
</Tooltip>
)}
</div>
<AnchoredPopover
open={recentOpen}
anchorRef={recentAnchorRef}
onClose={() => setRecentOpen(false)}
className="workspace-recent-menu"
align="start"
offset={6}
placement="bottom"
>
<div className="workspace-recent-menu__title">{t("workspace.recentFiles")}</div>
<div className="workspace-recent-menu__list">
{recentFiles.map((path) => (
<button
key={path}
type="button"
className={`workspace-recent-menu__item${path === selectedPath ? " workspace-recent-menu__item--active" : ""}`}
onClick={() => {
setSelectedPath(path);
setRecentOpen(false);
}}
>
<FileText size={14} />
<span>
<span className="workspace-recent-menu__name">{basename(path)}</span>
<span className="workspace-recent-menu__path">{parentPath(path)}</span>
</span>
</button>
))}
</div>
</AnchoredPopover>
</header>
<div className="workspace-preview__meta">
@@ -660,6 +736,7 @@ export function WorkspacePanel({
onClick={() => {
setFilter("");
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
}}
>
@@ -678,6 +755,7 @@ export function WorkspacePanel({
onClick={() => {
if (isLast) return;
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
setFilter("");
setOpenDirs((prev) => new Set([...Array.from(prev), ...breadcrumbDirs, dir]));
void loadDir(dir);
@@ -727,7 +805,25 @@ export function WorkspacePanel({
</div>
</section>}
{treeVisible && previewVisible && (
{previewVisible && !actualTreeVisible && (
<section className="workspace-tree-rail" aria-label={t("workspace.showTree")}>
<Tooltip label={t("workspace.showTree")} side="right">
<button
className="workspace-tree-reveal workspace-iconbtn workspace-iconbtn--on"
type="button"
aria-label={t("workspace.showTree")}
onClick={() => {
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
}}
>
<FolderTree size={15} />
</button>
</Tooltip>
</section>
)}
{actualTreeVisible && previewVisible && (
<button
className="workspace-tree-resizer"
type="button"
@@ -744,48 +840,56 @@ export function WorkspacePanel({
)}
<section className="workspace-files">
<div className="workspace-files__tools">
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
onClick={hideTreeOrClosePanel}
>
<PanelRightClose size={15} />
</button>
</Tooltip>
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
<button
className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => setViewMode("files")}
>
{t("workspace.filesTab")}
</button>
<button
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => {
setViewMode("changed");
void loadChanges();
}}
>
<GitBranch size={13} />
{t("workspace.changedTab")}
</button>
{showFileTools && (
<div className={`workspace-files__tools${embeddedDockMode ? " workspace-files__tools--embedded" : ""}`}>
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
type="button"
aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
onClick={hideTreeOrClosePanel}
>
{previewVisible ? <FolderX size={15} /> : <X size={15} />}
</button>
</Tooltip>
{showViewTabs && (
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
<button
className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => setViewMode("files")}
>
{t("workspace.filesTab")}
</button>
<button
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
onClick={() => {
setViewMode("changed");
void loadChanges();
}}
>
<GitBranch size={13} />
{t("workspace.changedTab")}
</button>
</div>
)}
{showViewTabs && (
<Tooltip label={t("workspace.refreshChanges")}>
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
<RefreshCw size={14} />
</button>
</Tooltip>
)}
</div>
<Tooltip label={t("workspace.refreshChanges")}>
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
<RefreshCw size={14} />
</button>
</Tooltip>
</div>
)}
<div className="workspace-search">
<Search size={14} />
<input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={t("workspace.filter")} />
<input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={searchPlaceholder} />
</div>
{viewMode === "changed" && changes && !changes.gitAvailable && changes.gitErr && (
<div className="workspace-note workspace-note--compact">{t("workspace.gitUnavailable")}</div>
)}
<div className="workspace-tree">
<div className="workspace-tree" onContextMenu={openTreeBlankMenu}>
{viewMode === "changed"
? renderChangedRows()
: flattened
@@ -842,6 +946,14 @@ export function WorkspacePanel({
/>
</FloatingMenu>
)}
<ContextMenu
open={Boolean(treeBlankMenuPoint)}
point={treeBlankMenuPoint}
items={treeBlankMenuItems}
minWidth={150}
ariaLabel={t("workspace.treeMenu")}
onClose={() => setTreeBlankMenuPoint(null)}
/>
</aside>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function asArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [];
}
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -1,6 +1,8 @@
// Last-resort crash surface: a React render error with no boundary unmounts the
// whole tree (blank window), and global errors/rejections leave no trace either.
import { t } from "./i18n";
function paint(text: string) {
let host = document.getElementById("crash-overlay");
if (!host) {
@@ -10,13 +12,13 @@ function paint(text: string) {
}
const title = document.createElement("div");
title.className = "crash-overlay__title";
title.textContent = "Reasonix hit an error — screenshot this and send it over";
title.textContent = t("crash.title");
const body = document.createElement("pre");
body.className = "crash-overlay__body";
body.textContent = text;
const copy = document.createElement("button");
copy.className = "crash-overlay__copy";
copy.textContent = "Copy";
copy.textContent = t("crash.copy");
copy.onclick = () => void navigator.clipboard?.writeText(text);
host.replaceChildren(title, body, copy);
}
+20 -12
View File
@@ -5,9 +5,10 @@
// non-React code (lib/tools.ts) translate too; it stays fresh because the provider
// updates it on every render.
//
// Desktop UI language is intentionally frontend-only. The CLI/kernel may still
// have its own `language` config for prompts and terminal text, but switching the
// desktop setting must not rewrite config or rebuild the model controller.
// Desktop UI language is intentionally separate from the CLI/kernel `language`
// config for prompts and terminal text. The desktop preference is persisted in
// the user-level [desktop] config; localStorage is only read once for legacy
// migration from older desktop builds.
import { createContext, useCallback, useContext, useState } from "react";
import type { ReactNode } from "react";
@@ -15,6 +16,7 @@ import { en, type DictKey } from "../locales/en";
import { zh } from "../locales/zh";
export type Locale = "en" | "zh";
export type { DictKey };
// LangPref is the stored preference: "" means auto-detect from the OS.
export type LangPref = "" | "en" | "zh";
@@ -48,16 +50,23 @@ export function detectLocale(pref: LangPref): Locale {
}
function readPref(): LangPref {
const v = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
return "";
}
export function normalizeLangPref(v: unknown): LangPref {
return v === "en" || v === "zh" ? v : "";
}
function writePref(pref: LangPref): void {
export function readLegacyLangPref(): LangPref {
const v = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
return normalizeLangPref(v);
}
export function clearLegacyLangPref(): void {
try {
if (pref) localStorage.setItem(STORAGE_KEY, pref);
else localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
} catch {
/* private mode / no storage — the in-memory state still applies this session */
/* private mode / no storage */
}
}
@@ -79,7 +88,7 @@ export function getLocale(): Locale {
return currentLocale;
}
type Translator = (key: DictKey, vars?: Record<string, string | number>) => string;
export type Translator = (key: DictKey, vars?: Record<string, string | number>) => string;
interface I18nValue {
locale: Locale;
@@ -95,10 +104,9 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
const locale = detectLocale(pref);
currentLocale = locale; // keep the mirror fresh for non-React callers
// setPref updates only the live UI and the browser cache.
// setPref updates only the live UI; persistence is handled by desktop config.
const setPref = useCallback((next: LangPref) => {
writePref(next);
setPrefState(next);
setPrefState(normalizeLangPref(next));
}, []);
const tt = useCallback<Translator>((key, vars) => translate(detectLocale(pref), key, vars), [pref]);
@@ -1,6 +1,8 @@
export type LayoutSizeKey =
| "sidebarWidth"
| "workspacePanelWidth"
| "rightDockWidth"
| "rightDockTreeWidth"
| "rightDockPreviewWidth"
| "workspaceFileTreePanelWidth"
| "workspaceTreeWidth"
| "composerHeight"
@@ -15,7 +17,9 @@ const STORAGE_KEY = "reasonix.layoutPreferences.v1";
const LEGACY_SIZE_KEYS: Record<LayoutSizeKey, string[]> = {
sidebarWidth: ["reasonix.sidebar.width"],
workspacePanelWidth: ["reasonix.workspacePanel.width"],
rightDockWidth: [],
rightDockTreeWidth: [],
rightDockPreviewWidth: [],
workspaceFileTreePanelWidth: [],
workspaceTreeWidth: ["reasonix.workspaceTree.width"],
composerHeight: ["reasonix.composerHeight"],
+24
View File
@@ -0,0 +1,24 @@
export type ProjectColorKey = "" | "red" | "orange" | "amber" | "green" | "teal" | "blue" | "purple" | "pink";
export interface ProjectColorOption {
key: ProjectColorKey;
value?: string;
}
export const PROJECT_COLOR_OPTIONS: ProjectColorOption[] = [
{ key: "" },
{ key: "red", value: "#e5534b" },
{ key: "orange", value: "#d66e4b" },
{ key: "amber", value: "#d59a2f" },
{ key: "green", value: "#4f9f64" },
{ key: "teal", value: "#1f9d93" },
{ key: "blue", value: "#3d7be0" },
{ key: "purple", value: "#8b6de8" },
{ key: "pink", value: "#cf6ca5" },
];
const colorValues = new Map(PROJECT_COLOR_OPTIONS.map((option) => [option.key, option.value]));
export function projectColorValue(key?: string): string | undefined {
return colorValues.get((key || "") as ProjectColorKey);
}
+86 -20
View File
@@ -1,6 +1,16 @@
// theme.ts manages the appearance override. The stylesheet follows the OS via
// prefers-color-scheme unless data-theme forces "dark" or "light". A separate
// data-theme-style attribute changes only accent tokens.
//
// When running inside the Wails shell, applyTheme also syncs the native window
// theme (title bar, traffic lights, etc.) so the OS chrome matches the webview.
import {
WindowSetDarkTheme,
WindowSetLightTheme,
WindowSetSystemDefaultTheme,
WindowSetBackgroundColour,
} from "../../wailsjs/runtime/runtime";
export type Theme = "auto" | "light" | "dark";
export type ResolvedTheme = Exclude<Theme, "auto">;
@@ -31,17 +41,22 @@ export const THEME_STYLE_THEME: Record<ThemeStyle, ResolvedTheme> = {
const DEFAULT_THEME_STYLE: Record<ResolvedTheme, ThemeStyle> = {
dark: "graphite",
light: "sandstone",
light: "glacier",
};
// New users default to the product's dark graphite look. Existing users, and any
// user who manually changes the theme, keep their stored desktop config.
const DEFAULT_THEME: Theme = "dark";
const THEME_KEY = "reasonix-theme";
const STYLE_KEY = "reasonix-theme-style";
let currentTheme: Theme = DEFAULT_THEME;
let currentThemeStyle: ThemeStyle = DEFAULT_THEME_STYLE.dark;
function normalizeTheme(value: unknown): Theme | null {
export function normalizeThemePreference(value: unknown): Theme {
if (typeof value === "object" && value !== null) {
return normalizeTheme((value as { mode?: unknown }).mode);
return normalizeThemePreference((value as { mode?: unknown }).mode);
}
if (typeof value !== "string") return null;
if (typeof value !== "string") return DEFAULT_THEME;
switch (value) {
case "auto":
return "auto";
@@ -54,7 +69,7 @@ function normalizeTheme(value: unknown): Theme | null {
case "contrast":
return "dark";
default:
return null;
return DEFAULT_THEME;
}
}
@@ -63,14 +78,7 @@ export function isThemeStyle(value: unknown): value is ThemeStyle {
}
export function getTheme(): Theme {
const v = typeof localStorage !== "undefined" ? localStorage.getItem(THEME_KEY) : null;
if (!v) return "auto";
try {
const parsed = JSON.parse(v) as unknown;
return normalizeTheme(parsed) ?? normalizeTheme(v) ?? "auto";
} catch {
return normalizeTheme(v) ?? "auto";
}
return currentTheme;
}
export function getResolvedTheme(theme: Theme = getTheme()): ResolvedTheme {
@@ -88,12 +96,15 @@ export function themeForStyle(style: ThemeStyle): ResolvedTheme {
}
export function getThemeStyle(theme: Theme = getTheme()): ThemeStyle {
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(STYLE_KEY) : null;
if (isThemeStyle(stored) && themeForStyle(stored) === getResolvedTheme(theme)) return stored;
if (theme === currentTheme && themeForStyle(currentThemeStyle) === getResolvedTheme(theme)) return currentThemeStyle;
return defaultStyleForTheme(theme);
}
export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme)): void {
export function normalizeThemeStyleForTheme(style: string | undefined, theme: Theme): ThemeStyle {
return isThemeStyle(style) && themeForStyle(style) === getResolvedTheme(theme) ? style : defaultStyleForTheme(theme);
}
export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme), options: { persist?: boolean } = {}): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.removeAttribute("data-theme-mode");
@@ -103,16 +114,71 @@ export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme
const resolved = getResolvedTheme(theme);
const nextStyle = themeForStyle(style) === resolved ? style : DEFAULT_THEME_STYLE[resolved];
currentTheme = theme;
currentThemeStyle = nextStyle;
root.setAttribute("data-theme-style", nextStyle);
// Sync the native window theme (title bar, traffic lights) to match.
if (typeof window !== "undefined" && window.runtime) {
if (theme === "auto") {
WindowSetSystemDefaultTheme();
} else if (theme === "light") {
WindowSetLightTheme();
} else if (theme === "dark") {
WindowSetDarkTheme();
}
}
void options;
}
export function readLegacyThemePreference(): { theme: Theme; style: ThemeStyle; hasValue: boolean } {
if (typeof localStorage === "undefined") return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE.dark, hasValue: false };
let rawTheme: string | null = null;
let rawStyle: string | null = null;
try {
localStorage.setItem(THEME_KEY, theme);
localStorage.setItem(STYLE_KEY, nextStyle);
rawTheme = localStorage.getItem(THEME_KEY);
rawStyle = localStorage.getItem(STYLE_KEY);
} catch {
/* private mode / no storage — the in-DOM attributes still apply */
return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE.dark, hasValue: false };
}
const hasValue = rawTheme !== null || rawStyle !== null;
let theme = DEFAULT_THEME;
if (rawTheme) {
try {
theme = normalizeThemePreference(JSON.parse(rawTheme) as unknown);
} catch {
theme = normalizeThemePreference(rawTheme);
}
}
const style = normalizeThemeStyleForTheme(rawStyle ?? undefined, theme);
return { theme, style, hasValue };
}
export function clearLegacyThemePreference(): void {
try {
localStorage.removeItem(THEME_KEY);
localStorage.removeItem(STYLE_KEY);
} catch {
/* ignore storage failures */
}
}
// initTheme runs before React mounts. It applies the saved theme to the DOM and
// sets the native window background colour to match the resolved theme, avoiding
// a white (or wrong-colour) flash while the webview paints its first frame.
export function initTheme(): void {
const theme = getTheme();
applyTheme(theme, getThemeStyle(theme));
applyTheme(theme, getThemeStyle(theme), { persist: false });
if (typeof window !== "undefined" && window.runtime) {
const resolved = getResolvedTheme(theme);
if (resolved === "light") {
// Light shell: matches :root[data-theme="light"] --bg (#f7f8fb).
WindowSetBackgroundColour(247, 248, 251, 255);
} else {
// Dark shell: matches :root --bg (#090a0c).
WindowSetBackgroundColour(9, 10, 12, 255);
}
}
}
+107 -20
View File
@@ -45,26 +45,16 @@ export interface WireUsage {
cacheHitTokens: number;
cacheMissTokens: number;
reasoningTokens?: number;
cacheDiagnostics?: WireCacheDiagnostics;
// Session-cumulative cache tokens — the status bar shows the aggregate
// hit-rate (Σhit/Σ(hit+miss)), steadier than the single-turn cacheHitTokens.
sessionCacheHitTokens: number;
sessionCacheMissTokens: number;
cost?: number;
currency?: string;
// Deprecated compatibility alias. Prefer cost + currency.
costUsd?: number;
}
export interface WireCacheDiagnostics {
prefixHash: string;
prefixChanged: boolean;
prefixChangeReasons?: string[];
systemHash: string;
toolsHash: string;
logRewriteVersion: number;
toolSchemaTokens: number;
cacheMissTokens: number;
cacheHitTokens: number;
}
export interface WireApproval {
id: string;
tool: string;
@@ -108,6 +98,90 @@ export interface WireEvent {
err?: string;
retryAttempt?: number;
retryMax?: number;
// Tab routing: set by the Go-side tabEventSink so multi-tab frontends
// route each event to the correct per-tab reducer.
tabId?: string;
sessionHitTokens?: number;
sessionMissTokens?: number;
sessionCost?: number;
sessionCurrency?: string;
// Deprecated compatibility alias. Prefer sessionCost + sessionCurrency.
sessionCostUsd?: number;
}
// Tab management types (desktop/tabs.go).
export interface TabMeta {
id: string;
tabType?: "session" | "file";
scope: string;
workspaceRoot: string;
workspaceName: string;
topicId: string;
topicTitle: string;
filePath?: string;
projectColor?: string;
label: string;
ready: boolean;
running: boolean;
mode: Mode;
startupErr?: string;
active: boolean;
cwd: string;
}
export interface ProjectNode {
key: string;
kind: "project" | "topic" | "global_folder" | "global_topic";
label: string;
root?: string;
topicId?: string;
projectColor?: string;
turns?: number;
lastActivityAt?: number;
open?: boolean;
running?: boolean;
children?: ProjectNode[];
}
export interface TopicMeta {
id: string;
title: string;
createdAt: number;
}
export interface ContextPanelInfo {
usedTokens: number;
windowTokens: number;
promptTokens: number;
completionTokens: number;
reasoningTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
sessionCost?: number;
sessionCurrency?: string;
// Deprecated compatibility alias. Prefer sessionCost + sessionCurrency.
sessionCostUsd?: number;
readFiles: ReadFileRecord[];
changedFiles: ChangedFileInfo[];
}
export interface ReadFileRecord {
path: string;
turn: number;
time: number;
offset?: number;
limit?: number;
truncated?: boolean;
}
export interface ChangedFileInfo {
path: string;
oldPath?: string;
sources: string[];
gitStatus?: string;
turns: number[];
latestPrompt?: string;
latestTime?: number;
}
// Bound-method payloads (desktop/app.go).
@@ -123,6 +197,8 @@ export interface CheckpointMeta {
prompt: string;
files: string[];
time: number; // unix ms
canCode?: boolean;
canConversation?: boolean;
}
// SessionMeta is one saved session for the history panel.
@@ -131,10 +207,15 @@ export interface SessionMeta {
preview: string;
title?: string; // user-chosen name; falls back to preview when empty
turns: number;
createdAt?: number; // unix milliseconds
lastActivityAt?: number; // unix milliseconds
createdAt: number; // unix milliseconds
lastActivityAt: number; // unix milliseconds
modTime: number; // compatibility alias for lastActivityAt
deletedAt?: number; // unix milliseconds, present for trashed sessions
current: boolean;
scope?: string; // "project" | "global"; empty for legacy → treated as "global"
workspaceRoot?: string;
topicId?: string;
topicTitle?: string;
}
export interface WorkspaceView {
@@ -146,6 +227,7 @@ export interface WorkspaceView {
export interface ContextInfo {
used: number;
window: number;
compactRatio?: number;
}
export interface Meta {
@@ -157,8 +239,8 @@ export interface Meta {
bypass?: boolean; // YOLO mode on (auto-approve every tool call)
}
// Mode is the input mode cycled by Shift+Tab: normal → plan (read-only) → yolo
// (auto-approve every tool call; deny rules still apply).
// Mode is the input mode cycled by Shift+Tab: normal (shown as auto) → plan
// (read-only) → yolo (auto-approve every tool call; deny rules still apply).
export type Mode = "normal" | "plan" | "yolo";
export interface CommandInfo {
@@ -224,14 +306,14 @@ export interface ServerView {
args?: string[];
url?: string;
envKeys?: string[];
authStatus?: string;
authUrl?: string;
authConfigured?: boolean;
tools: number;
prompts: number;
resources: number;
error?: string;
toolList?: MCPToolView[];
authStatus?: "none" | "possible" | "required" | string;
authUrl?: string;
authConfigured?: boolean;
}
export interface MCPToolView {
name: string;
@@ -242,6 +324,7 @@ export interface SkillView {
description: string;
scope: string;
runAs: string;
enabled: boolean;
}
export interface SkillRootSkillView {
name: string;
@@ -403,6 +486,10 @@ export interface SettingsView {
sandbox: SandboxView;
network: NetworkView;
agent: AgentView;
desktopLanguage: string; // "" | "en" | "zh"; empty = auto
desktopTheme: string; // "auto" | "dark" | "light"
desktopThemeStyle: string;
closeBehavior: string; // "background" | "quit"
configPath: string;
providerKinds: string[]; // provider implementations the kernel registered (for the kind picker)
bypass: boolean; // live YOLO state (runtime-only) — whether approvals are skipped this session
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
// useWindowStatePersistence polls the Wails runtime for window geometry and
// persists it via SaveWindowState so the next launch restores the same size and
// position. No-op in browser dev (no window.runtime).
//
// The Go shutdown hook (app.saveWindowStateSync) provides the authoritative
// final save; this hook covers moves/resizes during the session.
//
// NOTE: navigator.sendBeacon or a sync XHR would let us block beforeunload,
// but Wails bindings are async (Go IPC). We accept that the very last resize
// event may not land; the 5s poll and the Go shutdown hook make this unlikely
// to matter.
import { useEffect, useRef } from "react";
import {
WindowGetPosition,
WindowGetSize,
WindowIsMaximised,
} from "../../wailsjs/runtime/runtime";
import { app } from "./bridge";
export function useWindowStatePersistence() {
const lastState = useRef("");
useEffect(() => {
if (typeof window === "undefined" || !window.runtime) return;
let timer: ReturnType<typeof setInterval>;
const save = async () => {
try {
const size = await WindowGetSize();
const pos = await WindowGetPosition();
const maximised = await WindowIsMaximised();
const json = JSON.stringify({
width: size.w,
height: size.h,
x: pos.x,
y: pos.y,
maximised,
});
if (json !== lastState.current) {
await app.SaveWindowState({ width: size.w, height: size.h, x: pos.x, y: pos.y, maximised });
lastState.current = json;
}
} catch {
/* runtime not ready yet — poll will retry */
}
};
// Debounced save on resize (500ms after the last resize event).
let debounce: ReturnType<typeof setTimeout>;
const onResize = () => {
clearTimeout(debounce);
debounce = setTimeout(save, 500);
};
window.addEventListener("resize", onResize);
// Periodic poll every 5s for moves/maximise that don't trigger resize.
timer = setInterval(save, 5000);
// Best-effort save before the page unloads. The Go shutdown hook
// (saveWindowStateSync) is the authoritative final persist.
window.addEventListener("beforeunload", save);
return () => {
clearInterval(timer);
clearTimeout(debounce);
window.removeEventListener("resize", onResize);
window.removeEventListener("beforeunload", save);
};
}, []);
}
+239 -21
View File
@@ -27,11 +27,17 @@ export const en = {
"topbar.newSession": "New session",
"topbar.startupError": "startup error: {msg}",
// tab bar
"tabBar.closeTab": "Close tab",
"tabBar.closeOtherTabs": "Close other tabs",
"tabBar.closeTabsToRight": "Close tabs to right",
"tabBar.newSession": "New session",
"tabBar.tabActions": "Tab actions",
// sidebar
"sidebar.recent": "Recent",
"sidebar.conversations": "Chats",
"sidebar.viewAll": "All",
"sidebar.noRecent": "No recent chats",
"sidebar.allHistory": "All history",
"sidebar.trash": "Trash",
"sidebar.workspace": "Workspace",
"sidebar.changeWorkspace": "Change",
"sidebar.navigation": "Reasonix navigation",
@@ -41,20 +47,37 @@ export const en = {
"sidebar.resize": "Resize sidebar",
"drawer.resize": "Resize drawer",
// right dock
"rightDock.resize": "Resize right panel",
"rightDock.expand": "Expand right workspace",
"rightDock.collapse": "Collapse right workspace",
"rightDock.workbench": "Right workbench",
"rightDock.views": "Right workbench views",
"rightDock.overview": "Overview",
// topic bar
"topicBar.renameSession": "Rename session",
"topicBar.more": "More",
// scope labels
"scope.global": "Scope: Global",
"scope.project": "Project · {name}",
// workspace panel
"workspace.title": "Workspace",
"workspace.open": "Open workspace panel",
"workspace.close": "Close workspace panel",
"workspace.minimize": "Minimize workspace panel",
"workspace.maximize": "Maximize workspace panel",
"workspace.restore": "Restore workspace panel",
"workspace.resizePanel": "Resize workspace panel",
"workspace.hideTree": "Hide file tree",
"workspace.showTree": "Show file tree",
"workspace.resizeTree": "Resize file tree",
"workspace.newTab": "Open another file",
"workspace.closeTab": "Close tab",
"workspace.currentFile": "Current file",
"workspace.recentFiles": "Recent files",
"workspace.closePreview": "Close preview",
"workspace.filter": "Filter files…",
"workspace.filterChanges": "Filter changes…",
"workspace.noFile": "No file selected",
"workspace.pickFile": "Choose a file from the tree.",
"workspace.loading": "Loading file…",
@@ -66,7 +89,9 @@ export const en = {
"workspace.addFileContentToChat": "Add file contents",
"workspace.viewMode": "Workspace view",
"workspace.filesTab": "Files",
"workspace.changedTab": "Changed",
"workspace.changedTab": "Changes",
"workspace.treeMenu": "File tree actions",
"workspace.refreshTree": "Refresh file tree",
"workspace.refreshChanges": "Refresh changes",
"workspace.loadingChanges": "Loading changes…",
"workspace.noChanges": "No changed files",
@@ -106,6 +131,9 @@ export const en = {
"caps.skillScopeProject": "Project",
"caps.skillScopeCustom": "Custom",
"caps.skillScopeGlobal": "Global",
"caps.skillDisabled": "Disabled",
"caps.disableSkill": "Disable skill",
"caps.enableSkill": "Enable skill",
"caps.subagent": "Subagent",
"caps.addServer": "+ Add server",
"caps.add": "Add",
@@ -140,6 +168,13 @@ export const en = {
"caps.reauthorize": "Reauthorize",
"caps.checkCommand": "Check command",
"caps.builtIn": "Built-in",
"caps.auth": "Auth",
"caps.authRequired": "authorization required",
"caps.authRequiredSummary": "This MCP server requires authorization before it can connect.",
"caps.authPossible": "authorization may be required",
"caps.authPossibleShort": "auth may be required",
"caps.clearAuth": "Clear auth",
"caps.confirmClearAuth": "Confirm clear",
"caps.codegraphWarming": "codegraph: built-in code intelligence is still warming up or preparing the project index; retry shortly",
"caps.deferred": "enabled; connects when this MCP is used",
"caps.initializing": "connecting to MCP; tools will appear when ready",
@@ -194,18 +229,21 @@ export const en = {
"composer.planModeOn": "plan mode on",
"composer.planHint": "shift+tab",
"composer.planHintExit": "shift+tab to exit",
"composer.modeNormal": "normal",
"composer.modePlan": "plan mode",
"composer.modeNormal": "auto",
"composer.modePlan": "plan",
"composer.modeYolo": "YOLO",
"composer.modeHint": "shift+tab",
"composer.modeTitle": "Cycle mode (shift+tab): normal → plan → YOLO (auto-approve everything)",
"composer.modeTitle": "Cycle run mode (shift+tab): auto → plan → yolo",
"composer.enterPlanTitle": "Enter plan mode (shift+tab) — read-only; propose a plan before writing",
"composer.exitPlanTitle": "Exit plan mode (shift+tab)",
"composer.searchProjects": "Search projects",
"composer.noProjectMatches": "No matching projects",
"composer.addProject": "Add new project",
"composer.removeProject": "Remove project",
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
"composer.send": "Send (Enter)",
"composer.stop": "Stop (Esc)",
"composer.stopShort": "Stop",
"composer.pastedLabel": "[Pasted text #{id} · {lines} lines]",
"composer.pastedShowPreview": "Preview pasted text",
"composer.pastedHidePreview": "Hide pasted text preview",
@@ -218,7 +256,8 @@ export const en = {
// status bar
"status.connecting": "connecting…",
"status.ctx": "{pct}% ctx",
"status.ctx": "ctx {pct}%",
"status.compact": "compact {pct}%",
"status.plan": "PLAN",
"status.switchModel": "Switch model",
"status.noModels": "no models configured",
@@ -228,12 +267,18 @@ export const en = {
"status.switchFolder": "{cwd}\nClick to switch project folder",
"status.tokens": "tokens",
"status.retrying": "retrying ({attempt}/{max})…",
"status.cache": "cache {pct}%",
"status.cache": "turn hit {pct}%",
"status.cacheAvg": "avg {pct}%",
"status.ctxUnknown": "ctx -",
"status.compactUnknown": "compact -",
"status.cost": "run {amount}",
"status.balanceTitle": "Wallet balance",
"status.balance": "balance {amount}",
"status.spendTitle": "Spent this session",
"status.jobs": "{n} running",
"status.jobsCount": "jobs {n}",
"status.jobsTitle": "Background jobs",
"status.workspaceTitle": "Current workspace",
"status.yolo": "YOLO",
"status.yoloTitle": "YOLO — approvals are being skipped this session",
@@ -265,13 +310,27 @@ export const en = {
// history drawer
"history.title": "History",
"history.trashTitle": "Trash",
"history.empty": "No saved sessions yet.",
"history.trashEmpty": "Trash is empty.",
"history.trashHint": "Deleted sessions live here first. Restore them or delete them permanently.",
"history.namePlaceholder": "Session name…",
"history.emptySession": "(empty session)",
"history.current": "current",
"history.deleted": "deleted",
"history.turnOne": "{n} turn",
"history.turnOther": "{n} turns",
"history.confirmDelete": "Confirm delete",
"history.confirmPurge": "Confirm permanent delete",
"history.clearTrash": "Empty trash",
"history.confirmClearTrash": "Confirm empty trash",
"history.clearTrashMenu": "Empty trash…",
"history.moveToTrash": "Move to trash",
"history.confirmMoveToTrash": "Confirm move to trash",
"history.restore": "Restore",
"history.restoreSession": "Restore session",
"history.purge": "Delete permanently",
"history.purgeSession": "Delete permanently…",
"history.rename": "Rename",
"history.preview": "preview",
"history.previewEmpty": "No visible messages in this session.",
@@ -280,6 +339,42 @@ export const en = {
"history.noResults": "No matching sessions.",
"history.today": "Today",
"history.yesterday": "Yesterday",
"history.trashSessionActions": "Trash session actions",
"history.historySessionActions": "History session actions",
"history.trashActions": "Trash actions",
// project tree
"projectTree.workspaceTitle": "Project workspace",
"projectTree.searchPlaceholder": "Search projects or sessions",
"projectTree.newTopic": "New session",
"projectTree.renameTopic": "Rename session",
"projectTree.renameProject": "Rename display name",
"projectTree.projectHistory": "View project history",
"projectTree.removeProject": "Remove from sidebar",
"projectTree.confirmRemoveProject": "Confirm remove from sidebar",
"projectTree.copyPath": "Copy path",
"projectTree.revealInFinder": "Show in Finder",
"projectTree.revealInExplorer": "Show in File Explorer",
"projectTree.revealInFileManager": "Show in file manager",
"projectTree.topicActions": "Session actions",
"projectTree.projectActions": "Project actions",
"projectTree.colorDefault": "Default",
"projectTree.colorRed": "Red",
"projectTree.colorOrange": "Orange",
"projectTree.colorAmber": "Amber",
"projectTree.colorGreen": "Green",
"projectTree.colorTeal": "Teal",
"projectTree.colorBlue": "Blue",
"projectTree.colorPurple": "Purple",
"projectTree.colorPink": "Pink",
"projectTree.emptyNoMatch": "No matching projects or sessions",
"projectTree.emptyNoProjects": "No projects yet",
"projectTree.emptyNoProjectsDesc": "Add projects to organize sessions, files, and context.",
"projectTree.emptyAddProject": "Add new project",
"projectTree.emptyUseCurrentDir": "Use current directory",
"projectTree.emptyCurrentDir": "Current directory: {dir}",
"projectTree.newTopicTooltip": "New session",
"projectTree.addProjectTooltip": "Add new project",
// memory drawer
"memory.title": "Memory",
@@ -312,6 +407,7 @@ export const en = {
"settings.title": "Settings",
"settings.loading": "Loading…",
"settings.modelsProviders": "Models & providers",
"settings.tab.general": "General",
"settings.tab.models": "Models",
"settings.tab.providers": "Providers",
"settings.tab.network": "Network",
@@ -322,8 +418,14 @@ export const en = {
"settings.tab.updates": "Updates",
"settings.providerCount": "{n} providers",
"settings.agentMeta": "temp {temp} · steps {steps}",
"settings.appearanceMeta": "theme · accent · language",
"settings.appearanceMeta": "theme · accent",
"settings.updatesMeta": "version · config",
"settings.closeBehavior": "When closing window",
"settings.closeBehavior.background": "Keep running",
"settings.closeBehavior.quit": "Quit Reasonix",
"settings.closeBehaviorHint": "When keeping Reasonix running, closing the window hides the app. Use the Dock or menu to show it, or quit Reasonix from the menu.",
"settings.closeBehaviorHintMac": "When keeping Reasonix running, closing the window hides the app. Use the Dock or menu to show it, or quit Reasonix from the menu.",
"settings.closeBehaviorHintDesktop": "When keeping Reasonix running, closing the window hides the app. Launch Reasonix again to restore the existing window, or quit Reasonix from the menu.",
"settings.manageProviders": "Manage providers",
"settings.activeProvider": "Active provider",
"settings.plannerStatus": "Planner",
@@ -338,6 +440,7 @@ export const en = {
"settings.noKey": "no key",
"settings.cantDeleteDefault": "Can't delete the default provider",
"settings.deleteProvider": "Delete provider",
"settings.confirmDeleteProvider": "Confirm delete provider",
"settings.addProvider": "+ Add provider",
"settings.providerName": "name (e.g. deepseek-flash)",
"settings.providerKind": "Kind",
@@ -388,7 +491,7 @@ export const en = {
"settings.unlimited": "0 = unlimited",
"settings.systemPrompt": "System prompt",
"settings.saveAgent": "Save agent settings",
"settings.appearance": "Appearance & language",
"settings.appearance": "Appearance",
"settings.theme": "Theme",
"settings.themeStyle": "Accent",
"settings.themeAuto": "auto",
@@ -404,6 +507,7 @@ export const en = {
// todo bar
"todo.title": "To-dos",
"todo.dismiss": "Dismiss the task list",
"todo.stale": "Progress may be stale",
// slash menu tags
"slash.project": "project",
@@ -413,18 +517,40 @@ export const en = {
// assistant message
"msg.thinking": "thinking",
"msg.copy": "Copy",
"questionNav.label": "Question navigation",
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
"questionNav.hint": "Click to jump to this question",
"compaction.working": "Compacting conversation…",
"compaction.title": "Context compacted",
"compaction.messages": "{n} messages",
"compaction.showSummary": "show summary",
"compaction.hideSummary": "hide summary",
"rewind.label": "Rewind / fork / summarize…",
"rewind.both": "Code + conversation",
"rewind.conversation": "Conversation only",
"rewind.code": "Code only",
"rewind.fork": "Fork (new branch)",
"rewind.summFrom": "Summarize from here",
"rewind.summUpto": "Summarize up to here",
"rewind.label": "Message actions",
"rewind.disabledRunning": "Wait for the current action to finish",
"rewind.disabledNoCheckpoint": "This message has no restore point",
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
"rewind.disabledNoCode": "No code changes were captured after this message",
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
"rewind.filesChanged": "{count} files",
"rewind.both": "Rewind code and conversation",
"rewind.conversation": "Rewind conversation only",
"rewind.code": "Rewind code only",
"rewind.fork": "Fork conversation",
"rewind.summFrom": "Summarize after this",
"rewind.summUpto": "Summarize before this",
"rewind.confirmFork": "Confirm fork conversation",
"rewind.confirmSummFrom": "Confirm summarize after this",
"rewind.confirmSummUpto": "Confirm summarize before this",
"rewind.confirmBoth": "Confirm rewind code and conversation",
"rewind.confirmConversation": "Confirm rewind conversation",
"rewind.confirmCode": "Confirm rewind code",
"rewind.busyFork": "Forking from this message…",
"rewind.busySummFrom": "Summarizing after this message…",
"rewind.busySummUpto": "Summarizing up to this message…",
"rewind.busyConversation": "Rewinding conversation…",
"rewind.busyCode": "Rewinding code…",
"rewind.busyBoth": "Rewinding code and conversation…",
"msg.copied": "Copied",
// tool card summaries
@@ -477,6 +603,98 @@ export const en = {
"onboarding.error.network": "Couldn't reach DeepSeek — check your network and try again.",
"onboarding.error.unknown": "Something went wrong: {msg}",
"onboarding.skip": "Skip for now",
// context panel
"context.overview": "Session overview",
"context.scopeGlobal": "Scope: Global",
"context.scopeProject": "Project · {name}",
"context.sessionChanges": "Session changes",
"context.referencedFiles": "Referenced files",
"context.noChanges": "No changed files in this session yet",
"context.noReads": "No files read in this session yet",
"context.filterChanges": "Filter session changes...",
"context.filterReads": "Filter referenced files...",
"context.changedNote": "This session is linked to {count} change records",
"context.readNote": "This session has read {count} file records",
"context.changedMeta": "{count} change records",
"context.readMeta": "{count} read records",
"context.viewAll": "View all",
"context.health": "Context health",
"context.healthGood": "Context healthy",
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
"context.healthNearLimit": "Context near limit",
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
"context.healthManyFiles": "Many source files",
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
"context.healthLowCache": "Low cache hit",
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
"context.prompt": "Prompt",
"context.completion": "Completion",
"context.reasoning": "Reasoning",
"context.other": "Other",
"context.total": "Total",
"context.cacheHit": "Cache hit",
"context.sessionCost": "Session cost",
"context.requests": "Requests",
"context.time": "Time",
"context.durationSeconds": "{seconds}s",
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
// crash page
"crash.title": "Reasonix hit an error — screenshot this and send it over",
"crash.copy": "Copy",
// mock / demo seed data (browser dev only)
"mock.sessionFixLogin": "fix the login bug in auth.go",
"mock.sessionRefactor": "refactor the payment module",
"mock.sessionReadme": "write the README and badges",
"mock.sessionPlugin": "explain the plugin host design",
"mock.trashDevStandardTitle": "00 Establish project development standards",
"mock.trashDevStandardPreview": "Organize project development standards including directory structure, branch conventions, and commit requirements.",
"mock.trashP3aTitle": "20260521 p3a P&D",
"mock.trashP3aPreview": "Review p3a P&D scope, document remaining risks and next-round suggestions.",
"mock.trashGlobalProductTitle": "Common product specs",
"mock.trashGlobalProductPreview": "Document cross-project common product rules including acceptance, canary, and error handling.",
"mock.projectJoyquantDb": "joyquant-db",
"mock.projectJoyquantSys": "joyquant-sys",
"mock.topicDevStandard": "00 Establish project dev standards",
"mock.topicDbMaint": "01 JoyQuant DB project maintenance",
"mock.topicEnv": "Project environment uv and Python runtime issues",
"mock.topicP3b": "20260523 p3b P&D",
"mock.topicP3a": "20260521 p3a P&D",
"mock.topicHotfix": "20260522 post-p3-hotfix P&D",
"mock.topicSysCoord": "01 JoyQuant-SYS project coordination",
"mock.topicSysStandard": "00 Establish project dev standards",
"mock.topicProduct": "Common product specs",
"mock.topicAi": "AI engineering best practices",
"mock.topicLab": "Exploratory experiments",
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
"mock.askQ1Header": "Approach",
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
"mock.askQ1Opt1Label": "git stash then pull",
"mock.askQ1Opt1Desc": "Use git stash to save local changes, pull latest code, then restore",
"mock.askQ1Opt2Label": "Discard local changes then pull",
"mock.askQ1Opt2Desc": "Abandon all local changes, force sync with remote",
"mock.askQ1Opt3Label": "Create branch to save changes",
"mock.askQ1Opt3Desc": "Create a branch to save local changes first, then pull main branch",
"mock.askQ2Header": "Reasonix build",
"mock.askQ2Prompt": "What do you want to do about the missing reasonix binary?",
"mock.askQ2Opt1Label": "Check docs first",
"mock.askQ2Opt1Desc": "Read README / build docs to determine the correct build command",
"mock.askQ2Opt2Label": "Check build config",
"mock.askQ2Opt2Desc": "Check desktop/wails.json and main.go to infer the entry point",
"mock.askQ2Opt3Label": "Let me try building",
"mock.askQ2Opt3Desc": "Handle git conflicts first, then attempt local build and report results",
"mock.todo1": "Review current frontend structure",
"mock.todo2": "Optimize approval and Ask decision bar layout",
"mock.todo2ActiveForm": "Adjusting decision bar layout",
"mock.todo3": "Verify three preview states",
"mock.newSession": "New session",
"mock.memoryBody": "# User memory\n\nAlways respond in English.",
"mock.changedFile1Prompt": "Update draft spec v0.3",
"mock.changedFile2Prompt": "Record project context",
"mock.changedFile1Path": "docs/dev-standard.md",
"mock.changedFile2Path": ".reasonix/project.md",
};
export type DictKey = keyof typeof en;
+239 -21
View File
@@ -28,11 +28,17 @@ export const zh: Record<DictKey, string> = {
"topbar.newSession": "新建会话",
"topbar.startupError": "启动错误:{msg}",
// 标签页栏
"tabBar.closeTab": "关闭标签页",
"tabBar.closeOtherTabs": "关闭其他标签页",
"tabBar.closeTabsToRight": "关闭右侧标签页",
"tabBar.newSession": "新建会话",
"tabBar.tabActions": "标签页操作",
// 侧边栏
"sidebar.recent": "最近",
"sidebar.conversations": "会话",
"sidebar.viewAll": "全部",
"sidebar.noRecent": "还没有最近会话",
"sidebar.allHistory": "全部历史",
"sidebar.trash": "回收站",
"sidebar.workspace": "工作区",
"sidebar.changeWorkspace": "更改",
"sidebar.navigation": "Reasonix 导航",
@@ -42,20 +48,37 @@ export const zh: Record<DictKey, string> = {
"sidebar.resize": "调整侧边栏宽度",
"drawer.resize": "调整抽屉宽度",
// 右侧停靠区
"rightDock.resize": "调整右侧面板宽度",
"rightDock.expand": "展开右侧工作区",
"rightDock.collapse": "收起右侧工作区",
"rightDock.workbench": "右侧工作台",
"rightDock.views": "右侧工作台视图",
"rightDock.overview": "概览",
// 话题栏
"topicBar.renameSession": "重命名会话",
"topicBar.more": "更多",
// 范围标签
"scope.global": "范围:全局",
"scope.project": "项目 · {name}",
// 工作区面板
"workspace.title": "工作区",
"workspace.open": "打开工作区面板",
"workspace.close": "关闭工作区面板",
"workspace.minimize": "最小化工作区面板",
"workspace.maximize": "最大化工作区面板",
"workspace.restore": "还原工作区面板",
"workspace.resizePanel": "调整工作区面板宽度",
"workspace.hideTree": "隐藏文件树",
"workspace.showTree": "显示文件树",
"workspace.resizeTree": "调整文件树宽度",
"workspace.newTab": "打开其他文件",
"workspace.closeTab": "关闭标签",
"workspace.currentFile": "当前文件",
"workspace.recentFiles": "最近文件",
"workspace.closePreview": "关闭预览",
"workspace.filter": "筛选文件…",
"workspace.filterChanges": "筛选改动…",
"workspace.noFile": "未选择文件",
"workspace.pickFile": "从文件树中选择一个文件。",
"workspace.loading": "正在加载文件…",
@@ -68,7 +91,9 @@ export const zh: Record<DictKey, string> = {
"workspace.viewMode": "工作区视图",
"workspace.filesTab": "文件",
"workspace.changedTab": "改动",
"workspace.refreshChanges": "刷新改动文件",
"workspace.treeMenu": "文件树操作",
"workspace.refreshTree": "刷新文件树",
"workspace.refreshChanges": "刷新改动",
"workspace.loadingChanges": "正在加载改动…",
"workspace.noChanges": "暂无改动文件",
"workspace.gitUnavailable": "当前工作区不可读取 Git 状态。",
@@ -107,6 +132,9 @@ export const zh: Record<DictKey, string> = {
"caps.skillScopeProject": "项目",
"caps.skillScopeCustom": "自定义",
"caps.skillScopeGlobal": "全局",
"caps.skillDisabled": "已停用",
"caps.disableSkill": "停用技能",
"caps.enableSkill": "启用技能",
"caps.subagent": "子代理",
"caps.addServer": "+ 添加服务器",
"caps.add": "添加",
@@ -141,6 +169,13 @@ export const zh: Record<DictKey, string> = {
"caps.reauthorize": "重新授权",
"caps.checkCommand": "检查命令",
"caps.builtIn": "内置",
"caps.auth": "授权",
"caps.authRequired": "需要授权",
"caps.authRequiredSummary": "该 MCP 服务器需要完成授权后才能连接。",
"caps.authPossible": "可能需要授权",
"caps.authPossibleShort": "可能需要授权",
"caps.clearAuth": "清除授权",
"caps.confirmClearAuth": "确认清除",
"caps.codegraphWarming": "codegraph:内置代码智能仍在冷启动或准备项目索引,稍后重试即可",
"caps.deferred": "已启用,用到该 MCP 时连接",
"caps.initializing": "正在连接 MCP,工具可用后会自动出现",
@@ -195,18 +230,21 @@ export const zh: Record<DictKey, string> = {
"composer.planModeOn": "计划模式已开",
"composer.planHint": "shift+tab",
"composer.planHintExit": "shift+tab 退出",
"composer.modeNormal": "普通",
"composer.modePlan": "计划模式",
"composer.modeNormal": "auto",
"composer.modePlan": "plan",
"composer.modeYolo": "YOLO",
"composer.modeHint": "shift+tab",
"composer.modeTitle": "切换模式(shift+tab):普通 → 计划 → YOLO(全部自动批准)",
"composer.modeTitle": "切换运行模式(shift+tab):auto → plan → yolo",
"composer.enterPlanTitle": "进入计划模式(shift+tab)—— 只读;先给出计划再动手",
"composer.exitPlanTitle": "退出计划模式(shift+tab)",
"composer.searchProjects": "搜索项目",
"composer.noProjectMatches": "没有匹配的项目",
"composer.addProject": "添加新项目",
"composer.removeProject": "移除项目",
"composer.removeCurrentProjectDisabled": "不能移除当前项目",
"composer.send": "发送(Enter)",
"composer.stop": "停止(Esc)",
"composer.stopShort": "停止",
"composer.pastedLabel": "[已粘贴文本 #{id} · {lines} 行]",
"composer.pastedShowPreview": "预览粘贴文本",
"composer.pastedHidePreview": "收起粘贴文本预览",
@@ -219,7 +257,8 @@ export const zh: Record<DictKey, string> = {
// 状态栏
"status.connecting": "连接中…",
"status.ctx": "{pct}% 上下文",
"status.ctx": "上下文 {pct}%",
"status.compact": "压缩 {pct}%",
"status.plan": "计划",
"status.switchModel": "切换模型",
"status.noModels": "未配置任何模型",
@@ -229,12 +268,18 @@ export const zh: Record<DictKey, string> = {
"status.switchFolder": "{cwd}\n点击切换项目目录",
"status.tokens": "tokens",
"status.retrying": "正在重试 ({attempt}/{max})…",
"status.cache": "缓存 {pct}%",
"status.cache": "本次命中 {pct}%",
"status.cacheAvg": "平均 {pct}%",
"status.ctxUnknown": "上下文 -",
"status.compactUnknown": "压缩 -",
"status.cost": "本次 {amount}",
"status.balanceTitle": "钱包余额",
"status.balance": "余额 {amount}",
"status.spendTitle": "本会话花费",
"status.jobs": "{n} 个运行中",
"status.jobsCount": "任务 {n}",
"status.jobsTitle": "后台作业",
"status.workspaceTitle": "当前工作区",
"status.yolo": "YOLO",
"status.yoloTitle": "YOLO —— 本会话正在跳过所有批准",
@@ -266,13 +311,27 @@ export const zh: Record<DictKey, string> = {
// 历史抽屉
"history.title": "历史",
"history.trashTitle": "回收站",
"history.empty": "还没有已保存的会话。",
"history.trashEmpty": "回收站为空。",
"history.trashHint": "删除的会话会先放在这里,可恢复或彻底删除。",
"history.namePlaceholder": "会话名称…",
"history.emptySession": "(空会话)",
"history.current": "当前",
"history.deleted": "已删除",
"history.turnOne": "{n} 轮",
"history.turnOther": "{n} 轮",
"history.confirmDelete": "确认删除",
"history.confirmPurge": "确认彻底删除",
"history.clearTrash": "清空回收站",
"history.confirmClearTrash": "确认清空回收站",
"history.clearTrashMenu": "清空回收站…",
"history.moveToTrash": "移到回收站",
"history.confirmMoveToTrash": "确认移入回收站",
"history.restore": "恢复",
"history.restoreSession": "恢复会话",
"history.purge": "彻底删除",
"history.purgeSession": "彻底删除…",
"history.rename": "重命名",
"history.preview": "预览",
"history.previewEmpty": "这个会话没有可见消息。",
@@ -281,6 +340,42 @@ export const zh: Record<DictKey, string> = {
"history.noResults": "没有匹配的会话。",
"history.today": "今天",
"history.yesterday": "昨天",
"history.trashSessionActions": "回收站会话操作",
"history.historySessionActions": "历史会话操作",
"history.trashActions": "回收站操作",
// 项目树
"projectTree.workspaceTitle": "项目工作区",
"projectTree.searchPlaceholder": "搜索项目或会话",
"projectTree.newTopic": "新会话",
"projectTree.renameTopic": "重命名会话",
"projectTree.renameProject": "修改显示名称",
"projectTree.projectHistory": "查看项目历史",
"projectTree.removeProject": "移出侧边栏",
"projectTree.confirmRemoveProject": "确认移出侧边栏",
"projectTree.copyPath": "复制路径",
"projectTree.revealInFinder": "在 Finder 中显示",
"projectTree.revealInExplorer": "在文件资源管理器中显示",
"projectTree.revealInFileManager": "在文件管理器中显示",
"projectTree.topicActions": "会话操作",
"projectTree.projectActions": "项目操作",
"projectTree.colorDefault": "默认",
"projectTree.colorRed": "红色",
"projectTree.colorOrange": "橙色",
"projectTree.colorAmber": "琥珀",
"projectTree.colorGreen": "绿色",
"projectTree.colorTeal": "青色",
"projectTree.colorBlue": "蓝色",
"projectTree.colorPurple": "紫色",
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
"projectTree.emptyNoProjects": "还没有项目",
"projectTree.emptyNoProjectsDesc": "添加项目后,可以按项目管理会话、文件和上下文。",
"projectTree.emptyAddProject": "添加新项目",
"projectTree.emptyUseCurrentDir": "使用当前目录",
"projectTree.emptyCurrentDir": "当前目录:{dir}",
"projectTree.newTopicTooltip": "新建会话",
"projectTree.addProjectTooltip": "添加新项目",
// 记忆抽屉
"memory.title": "记忆",
@@ -313,6 +408,7 @@ export const zh: Record<DictKey, string> = {
"settings.title": "设置",
"settings.loading": "加载中…",
"settings.modelsProviders": "模型与模型服务",
"settings.tab.general": "通用",
"settings.tab.models": "模型",
"settings.tab.providers": "模型服务",
"settings.tab.network": "网络",
@@ -323,8 +419,14 @@ export const zh: Record<DictKey, string> = {
"settings.tab.updates": "更新",
"settings.providerCount": "{n} 个服务",
"settings.agentMeta": "温度 {temp} · 步数 {steps}",
"settings.appearanceMeta": "主题 · 强调色 · 语言",
"settings.appearanceMeta": "主题 · 强调色",
"settings.updatesMeta": "版本 · 配置",
"settings.closeBehavior": "关闭窗口时",
"settings.closeBehavior.background": "保持后台运行",
"settings.closeBehavior.quit": "退出 Reasonix",
"settings.closeBehaviorHint": "保持后台运行时,关闭窗口会隐藏应用;可从 Dock 或菜单中显示窗口,也可从菜单退出 Reasonix。",
"settings.closeBehaviorHintMac": "保持后台运行时,关闭窗口会隐藏应用;可从 Dock 或菜单中显示窗口,也可从菜单退出 Reasonix。",
"settings.closeBehaviorHintDesktop": "保持后台运行时,关闭窗口会隐藏应用;再次启动 Reasonix 会恢复已有窗口,也可从菜单退出 Reasonix。",
"settings.manageProviders": "管理模型服务",
"settings.activeProvider": "当前模型服务",
"settings.plannerStatus": "规划模型",
@@ -339,6 +441,7 @@ export const zh: Record<DictKey, string> = {
"settings.noKey": "无密钥",
"settings.cantDeleteDefault": "无法删除默认模型服务",
"settings.deleteProvider": "删除模型服务",
"settings.confirmDeleteProvider": "确认删除模型服务",
"settings.addProvider": "+ 添加模型服务",
"settings.providerName": "名称(如 deepseek-flash)",
"settings.providerKind": "类型",
@@ -389,7 +492,7 @@ export const zh: Record<DictKey, string> = {
"settings.unlimited": "0 = 不限",
"settings.systemPrompt": "系统提示词",
"settings.saveAgent": "保存智能体设置",
"settings.appearance": "外观与语言",
"settings.appearance": "外观",
"settings.theme": "主题",
"settings.themeStyle": "强调色",
"settings.themeAuto": "自动",
@@ -405,6 +508,7 @@ export const zh: Record<DictKey, string> = {
// 待办栏
"todo.title": "待办",
"todo.dismiss": "关闭待办列表",
"todo.stale": "进度可能未同步",
// 斜杠菜单标签
"slash.project": "项目",
@@ -414,18 +518,40 @@ export const zh: Record<DictKey, string> = {
// 助手消息
"msg.thinking": "思考过程",
"msg.copy": "复制",
"questionNav.label": "问题导航",
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
"questionNav.hint": "点击跳转到这次提问",
"compaction.working": "正在压缩对话…",
"compaction.title": "上下文已压缩",
"compaction.messages": "{n} 条消息",
"compaction.showSummary": "展开摘要",
"compaction.hideSummary": "收起摘要",
"rewind.label": "回滚 / 分叉 / 摘要…",
"rewind.both": "代码 + 对话",
"rewind.conversation": "仅对话",
"rewind.code": "仅代码",
"rewind.fork": "从这里分叉",
"rewind.summFrom": "总结后续内容",
"rewind.summUpto": "总结到这里",
"rewind.label": "消息操作",
"rewind.disabledRunning": "等待当前操作结束",
"rewind.disabledNoCheckpoint": "这条消息没有可恢复点",
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
"rewind.filesChanged": "{count} 个文件",
"rewind.both": "回滚代码和对话",
"rewind.conversation": "仅回滚对话",
"rewind.code": "仅回滚代码",
"rewind.fork": "分叉会话",
"rewind.summFrom": "总结这条之后",
"rewind.summUpto": "总结这条之前",
"rewind.confirmFork": "确认分叉会话",
"rewind.confirmSummFrom": "确认总结这条之后",
"rewind.confirmSummUpto": "确认总结这条之前",
"rewind.confirmBoth": "确认回滚代码和对话",
"rewind.confirmConversation": "确认回滚对话",
"rewind.confirmCode": "确认回滚代码",
"rewind.busyFork": "正在从这条消息分叉…",
"rewind.busySummFrom": "正在总结这条消息之后的内容…",
"rewind.busySummUpto": "正在总结到这条消息…",
"rewind.busyConversation": "正在回滚对话…",
"rewind.busyCode": "正在回滚代码…",
"rewind.busyBoth": "正在回滚代码和对话…",
"msg.copied": "已复制",
// 工具卡片摘要
@@ -478,4 +604,96 @@ export const zh: Record<DictKey, string> = {
"onboarding.error.network": "无法连接 DeepSeek —— 检查网络后再试。",
"onboarding.error.unknown": "出错啦:{msg}",
"onboarding.skip": "稍后设置",
// 上下文面板
"context.overview": "当前会话概览",
"context.scopeGlobal": "范围:全局",
"context.scopeProject": "项目 · {name}",
"context.sessionChanges": "本会话变更",
"context.referencedFiles": "依据文件",
"context.noChanges": "当前会话还没有变更文件",
"context.noReads": "当前会话还没有读取文件",
"context.filterChanges": "筛选本会话变更文件...",
"context.filterReads": "筛选依据文件...",
"context.changedNote": "当前会话关联 {count} 条变更记录",
"context.readNote": "当前会话读取过 {count} 条文件记录",
"context.changedMeta": "{count} 条变更记录",
"context.readMeta": "{count} 条读取记录",
"context.viewAll": "查看全部",
"context.health": "上下文状态",
"context.healthGood": "上下文状态正常",
"context.healthGoodBody": "用量、依据文件和本会话变更保持可追踪。",
"context.healthNearLimit": "上下文接近上限",
"context.healthNearLimitBody": "已使用 {pct}%,优先保留关键依据并考虑压缩。",
"context.healthManyFiles": "依据文件较多",
"context.healthManyFilesBody": "已读取 {count} 条文件记录,可查看依据文件确认是否仍相关。",
"context.healthLowCache": "缓存命中偏低",
"context.healthLowCacheBody": "当前缓存命中 {pct}%,后续长任务可关注上下文稳定性。",
"context.prompt": "Prompt",
"context.completion": "Completion",
"context.reasoning": "Reasoning",
"context.other": "Other",
"context.total": "Total",
"context.cacheHit": "缓存命中",
"context.sessionCost": "本次费用",
"context.requests": "请求数",
"context.time": "耗时",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
// 崩溃兜底页
"crash.title": "Reasonix 遇到错误 —— 请截图发送",
"crash.copy": "复制",
// 模拟/演示种子数据(仅浏览器开发模式)
"mock.sessionFixLogin": "fix the login bug in auth.go",
"mock.sessionRefactor": "refactor the payment module",
"mock.sessionReadme": "write the README and badges",
"mock.sessionPlugin": "explain the plugin host design",
"mock.trashDevStandardTitle": "00 制定项目开发规范",
"mock.trashDevStandardPreview": "整理项目开发规范,包含目录结构、分支约定和提交要求。",
"mock.trashP3aTitle": "20260521 p3a P&D",
"mock.trashP3aPreview": "复盘 p3a P&D 需求范围,记录遗留风险和下轮处理建议。",
"mock.trashGlobalProductTitle": "产品通用规范",
"mock.trashGlobalProductPreview": "沉淀跨项目通用产品规则,包含验收、灰度和异常处理。",
"mock.projectJoyquantDb": "joyquant-db",
"mock.projectJoyquantSys": "joyquant-sys",
"mock.topicDevStandard": "00 制定项目开发规范",
"mock.topicDbMaint": "01 JoyQuant DB项目维护",
"mock.topicEnv": "项目环境uv和Python运行问题",
"mock.topicP3b": "20260523 p3b P&D",
"mock.topicP3a": "20260521 p3a P&D",
"mock.topicHotfix": "20260522 post-p3-hotfix P&D",
"mock.topicSysCoord": "01 JoyQuant-SYS 项目总协调",
"mock.topicSysStandard": "00 制定项目开发规范",
"mock.topicProduct": "产品通用规范",
"mock.topicAi": "AI 工程化最佳实践",
"mock.topicLab": "临时探索与实验",
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
"mock.askQ1Header": "处理方向",
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
"mock.askQ1Opt1Label": "git stash 后 pull",
"mock.askQ1Opt1Desc": "用 git stash 暂存本地修改,拉取最新代码后再恢复",
"mock.askQ1Opt2Label": "丢弃本地修改后 pull",
"mock.askQ1Opt2Desc": "放弃本地所有修改,强制与远端同步",
"mock.askQ1Opt3Label": "另建分支保存改动",
"mock.askQ1Opt3Desc": "先创建分支把本地改动保存起来,再拉取主分支",
"mock.askQ2Header": "Reasonix 构建",
"mock.askQ2Prompt": "对于 reasonix 二进制缺失的问题,你想怎么做?",
"mock.askQ2Opt1Label": "先查文档",
"mock.askQ2Opt1Desc": "查看 README / 构建文档来确定正确的构建命令",
"mock.askQ2Opt2Label": "看构建配置",
"mock.askQ2Opt2Desc": "查看 desktop/wails.json 与 main.go 来推断入口",
"mock.askQ2Opt3Label": "我先帮你尝试构建",
"mock.askQ2Opt3Desc": "先处理 git 冲突,然后尝试本地构建并汇报结果",
"mock.todo1": "梳理当前前端结构",
"mock.todo2": "优化审批与 Ask 决策条布局",
"mock.todo2ActiveForm": "正在调整决策条布局",
"mock.todo3": "验证三种预览状态",
"mock.newSession": "新的会话",
"mock.memoryBody": "# 用户记忆\n\n始终用中文回复。",
"mock.changedFile1Prompt": "更新规范草案 v0.3",
"mock.changedFile2Prompt": "记录项目上下文",
"mock.changedFile1Path": "docs/dev-standard.md",
"mock.changedFile2Path": ".reasonix/project.md",
};
+20
View File
@@ -9,6 +9,26 @@ import "./styles.css";
// Apply the saved appearance (auto/light/dark) before the first paint.
initTheme();
// Pre-warm font fallback stacks so the first frame doesn't flicker between the
// browser default font and the app's configured typeface. Inserting a hidden span
// with CJK + emoji + math glyphs forces the OS font subsystem to resolve and
// cache the fallback chains before React mounts.
function prewarmFontFallbacks() {
const span = document.createElement("span");
span.style.cssText = "position:absolute;visibility:hidden;font-size:1px;pointer-events:none";
span.textContent = "中文日本語한국어 математика 😀🎉✓⚠∑∏∫";
document.body.appendChild(span);
// Force layout so the browser resolves font fallback chains.
void span.offsetHeight;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
span.remove();
});
});
}
prewarmFontFallbacks();
installGlobalCrashHandlers();
// Inside the Wails shell, suppress the webview's default right-click menu — its
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -14,14 +14,13 @@ require reasonix v0.0.0
require (
aead.dev/minisign v0.3.0
github.com/minio/selfupdate v0.6.0
github.com/wailsapp/wails/v2 v2.12.0
github.com/wailsapp/wails/v2 v2.11.0
golang.org/x/mod v0.36.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+2 -4
View File
@@ -1,8 +1,6 @@
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
@@ -74,8 +72,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+58
View File
@@ -1,10 +1,14 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
@@ -61,3 +65,57 @@ func TestPreviewSessionMessagesLoadsWithoutResuming(t *testing.T) {
t.Fatalf("preview reasoning = %q, want saved reasoning", got[1].Reasoning)
}
}
func TestResumeSessionForTabTargetsSpecifiedTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
activePath := filepath.Join(dir, "active.jsonl")
inactivePath := filepath.Join(dir, "inactive.jsonl")
targetPath := filepath.Join(dir, "target.jsonl")
writeHistoryTestSession(t, activePath, "active prompt")
writeHistoryTestSession(t, inactivePath, "inactive prompt")
writeHistoryTestSession(t, targetPath, "target prompt")
activeExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
inactiveExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
activeCtrl := control.New(control.Options{Executor: activeExec, SessionDir: dir, SessionPath: activePath, Label: "active"})
inactiveCtrl := control.New(control.Options{Executor: inactiveExec, SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
defer activeCtrl.Close()
defer inactiveCtrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{
"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
},
tabOrder: []string{"active", "inactive"},
activeTabID: "active",
}
got, err := app.ResumeSessionForTab("inactive", targetPath)
if err != nil {
t.Fatalf("ResumeSessionForTab: %v", err)
}
if activeCtrl.SessionPath() != activePath {
t.Fatalf("active tab session path = %q, want %q", activeCtrl.SessionPath(), activePath)
}
if inactiveCtrl.SessionPath() != targetPath {
t.Fatalf("inactive tab session path = %q, want %q", inactiveCtrl.SessionPath(), targetPath)
}
if len(got) != 1 || got[0].Content != "target prompt" {
t.Fatalf("resumed history = %+v, want target prompt", got)
}
}
func writeHistoryTestSession(t *testing.T, path, prompt string) {
t.Helper()
session := agent.NewSession("")
session.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
if err := session.Save(path); err != nil {
t.Fatalf("Save %s: %v", path, err)
}
}
+37 -24
View File
@@ -39,20 +39,40 @@ var version = "dev"
func main() {
app := NewApp()
// Restore saved window size, or fall back to the default.
width, height := 1240, 720
if saved, ok := loadWindowState(); ok {
if saved.Width > 0 {
width = saved.Width
}
if saved.Height > 0 {
height = saved.Height
}
}
err := wails.Run(&options.App{
Title: "Reasonix",
Width: 1240,
Height: 720,
Width: width,
Height: height,
MinWidth: 760,
MinHeight: 480,
// Match the dark UI shell so first paint (before CSS loads) doesn't flash
// white — particularly visible on WebKitGTK. Uses the dark theme's --bg
// colour (#1a1a2e = RGB 26,26,46).
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
OnShutdown: app.shutdown,
Bind: []any{app},
// Match the dark UI shell so the initial webview background doesn't flash
// white before CSS loads — particularly visible on WebKitGTK.
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose,
OnShutdown: app.shutdown,
Bind: []any{app},
SingleInstanceLock: singleInstanceLock(app),
// Start hidden — domReady positions and shows the window after restoring
// geometry, so the user never sees the default size/position flash.
StartHidden: true,
// Native application menu (File > Settings, Edit, Window).
Menu: app.createAppMenu(),
// Native OS file drops: the webview withholds dropped files' paths from the
// HTML drop event, so the frontend (composer) reads them via runtime.OnFileDrop
@@ -63,22 +83,15 @@ func main() {
Mac: &mac.Options{
// Inset traffic-lights over a frameless-feeling header; the frontend
// leaves a drag region at the top (CSS --wails-draggable).
TitleBar: mac.TitleBarHiddenInset(),
Appearance: mac.NSAppearanceNameDarkAqua,
TitleBar: mac.TitleBarHiddenInset(),
// Follow the OS appearance so the title bar matches light/dark system
// preference instead of being locked to dark.
Appearance: mac.DefaultAppearance,
},
Windows: &windows.Options{
// Paint the OS title bar with the app's dark shell colour instead of the
// default light caption that clashed against the dark UI on light-mode
// Windows (#2793). Both modes use the shell colour so it always blends.
Theme: windows.Dark,
CustomTheme: &windows.ThemeSettings{
DarkModeTitleBar: windows.RGB(26, 26, 46),
DarkModeTitleText: windows.RGB(228, 228, 238),
DarkModeBorder: windows.RGB(26, 26, 46),
LightModeTitleBar: windows.RGB(26, 26, 46),
LightModeTitleText: windows.RGB(228, 228, 238),
LightModeBorder: windows.RGB(26, 26, 46),
},
// Follow the OS theme so the title bar matches light/dark system
// preference instead of being locked to dark.
Theme: windows.SystemDefault,
},
Linux: &linux.Options{
ProgramName: "Reasonix",
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// createAppMenu builds the native application menu bar. It emits app:open-settings
// on CmdOrCtrl+, so the frontend can open the settings drawer without a global
// keyboard listener. Edit and Window menus use Wails standard roles for platform-
// correct Undo/Redo/Cut/Copy/Paste/SelectAll and Minimize/Zoom/Fullscreen.
func (a *App) createAppMenu() *menu.Menu {
m := menu.NewMenu()
m.Append(menu.AppMenu())
fileMenu := m.AddSubmenu("File")
fileMenu.AddText("Settings", keys.CmdOrCtrl(","), func(_ *menu.CallbackData) {
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "app:open-settings")
}
})
fileMenu.AddText("Show Reasonix", nil, func(_ *menu.CallbackData) {
a.showMainWindow()
})
fileMenu.AddText("Quit Reasonix", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
a.quitApp()
})
m.Append(menu.EditMenu())
m.Append(menu.WindowMenu())
return m
}
+181 -6
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/fileutil"
)
@@ -24,9 +25,12 @@ var errActiveSession = errors.New("can't delete the session you're in — start
const sessionTitlesFile = ".titles.json"
const sessionDisplayFile = ".display.json"
const sessionTrashDir = ".trash"
const sessionTrashMetaFile = ".trash-meta.json"
func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) }
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }
// loadSessionTitles reads the basename→title map (missing/corrupt → empty).
func loadSessionTitles(dir string) map[string]string {
@@ -81,23 +85,133 @@ func setSessionTitle(dir, sessionPath, title string) error {
return saveSessionTitles(dir, m)
}
// deleteSessionFile removes a session's .jsonl and its sidecar state.
// deleteSessionFile moves a session's .jsonl and file sidecars into the local
// trash. Title/display sidecars stay in place so trash previews and restores can
// preserve the user's labels.
func deleteSessionFile(dir, sessionPath string) error {
sessionPath, key, err := validateSessionPath(dir, sessionPath)
if err != nil {
return err
}
return removeSessionArtifacts(dir, sessionPath, key)
return trashSessionArtifacts(dir, sessionPath, key)
}
func removeSessionArtifacts(dir, sessionPath, key string) error {
if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) {
type trashedSessionMeta struct {
Key string `json:"key"`
DeletedAt int64 `json:"deletedAt"`
}
func trashSessionArtifacts(dir, sessionPath, key string) error {
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
return nil
} else if err != nil {
return err
}
if err := os.Remove(sessionPath + ".meta"); err != nil && !os.IsNotExist(err) {
itemDir := filepath.Join(sessionTrashPath(dir), key)
if _, err := os.Stat(itemDir); err == nil {
return fmt.Errorf("session already exists in trash: %s", key)
} else if !os.IsNotExist(err) {
return err
}
if err := os.RemoveAll(strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"); err != nil {
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
if err := movePathIfExists(sessionPath, filepath.Join(itemDir, key)); err != nil {
return err
}
if err := movePathIfExists(sessionPath+".meta", filepath.Join(itemDir, key+".meta")); err != nil {
return err
}
ckptName := strings.TrimSuffix(key, ".jsonl") + ".ckpt"
if err := movePathIfExists(strings.TrimSuffix(sessionPath, ".jsonl")+".ckpt", filepath.Join(itemDir, ckptName)); err != nil {
return err
}
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
return err
}
return nil
}
func listTrashedSessionFiles(dir string) ([]string, error) {
root := sessionTrashPath(dir)
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
paths := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
key := e.Name()
if filepath.Ext(key) != ".jsonl" || filepath.Base(key) != key {
continue
}
path := filepath.Join(root, key, key)
validPath, _, _, err := validateTrashedSessionPath(dir, path)
if err != nil {
continue
}
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
paths = append(paths, validPath)
}
}
return paths, nil
}
func trashedSessionDeletedAt(path string) int64 {
b, err := os.ReadFile(filepath.Join(filepath.Dir(path), sessionTrashMetaFile))
if err != nil {
return 0
}
var meta trashedSessionMeta
if err := json.Unmarshal(b, &meta); err != nil {
return 0
}
return meta.DeletedAt
}
func restoreTrashedSessionFile(dir, path string) error {
trashPath, key, itemDir, err := validateTrashedSessionPath(dir, path)
if err != nil {
return err
}
target := filepath.Join(dir, key)
if _, err := os.Stat(target); err == nil {
return fmt.Errorf("session already exists: %s", key)
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := movePathIfExists(trashPath, target); err != nil {
return err
}
if err := movePathIfExists(trashPath+".meta", target+".meta"); err != nil {
return err
}
ckptName := strings.TrimSuffix(key, ".jsonl") + ".ckpt"
if err := movePathIfExists(filepath.Join(itemDir, ckptName), filepath.Join(dir, ckptName)); err != nil {
return err
}
return os.RemoveAll(itemDir)
}
func purgeTrashedSessionFile(dir, path string) error {
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
if err != nil {
return err
}
if err := os.RemoveAll(itemDir); err != nil {
return err
}
m := loadSessionTitles(dir)
@@ -116,6 +230,18 @@ func removeSessionArtifacts(dir, sessionPath, key string) error {
return nil
}
func movePathIfExists(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil
} else if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Rename(src, dst)
}
func validateSessionPath(dir, sessionPath string) (string, string, error) {
if strings.TrimSpace(sessionPath) == "" {
return "", "", fmt.Errorf("empty session path")
@@ -161,6 +287,55 @@ func validateSessionPath(dir, sessionPath string) (string, string, error) {
return absPath, filepath.Base(absPath), nil
}
func validateTrashedSessionPath(dir, sessionPath string) (string, string, string, error) {
if strings.TrimSpace(sessionPath) == "" {
return "", "", "", fmt.Errorf("empty session path")
}
root, err := filepath.Abs(sessionTrashPath(dir))
if err != nil {
return "", "", "", err
}
path := sessionPath
if !filepath.IsAbs(path) {
path = filepath.Join(root, path)
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", "", "", err
}
if filepath.Ext(absPath) != ".jsonl" {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
rel, err := filepath.Rel(root, absPath)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || parts[0] != parts[1] {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
if info, err := os.Lstat(absPath); err == nil {
if info.IsDir() {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
realRoot, dirErr := filepath.EvalSymlinks(root)
if dirErr != nil {
realRoot = root
}
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return "", "", "", err
}
rel, err := filepath.Rel(realRoot, realPath)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return "", "", "", fmt.Errorf("session path escapes trash dir: %s", sessionPath)
}
} else if !os.IsNotExist(err) {
return "", "", "", err
}
return absPath, filepath.Base(absPath), filepath.Dir(absPath), nil
}
type sessionDisplayMap map[string]map[string]string
func messageDisplayKey(content string) string {
+120 -10
View File
@@ -131,23 +131,36 @@ func TestDeleteSessionFile(t *testing.T) {
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("delete: %v", err)
}
// File should be gone.
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
trashMetaPath := trashPath + ".meta"
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
// File should be moved out of the active session list.
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Error("session file should be deleted")
t.Error("session file should be removed from active sessions")
}
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
t.Error("session meta should be deleted")
t.Error("session meta should be removed from active sessions")
}
if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
t.Error("session checkpoints should be deleted")
t.Error("session checkpoints should be removed from active sessions")
}
// Title should be gone.
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("session file should be in trash: %v", err)
}
if _, err := os.Stat(trashMetaPath); err != nil {
t.Fatalf("session meta should be in trash: %v", err)
}
if _, err := os.Stat(trashCkptDir); err != nil {
t.Fatalf("session checkpoints should be in trash: %v", err)
}
// Title/display should be retained until permanent deletion.
m := loadSessionTitles(dir)
if _, ok := m["session.jsonl"]; ok {
t.Error("title should be removed after delete")
if m["session.jsonl"] != "My Title" {
t.Errorf("title should be retained in trash, got %q", m["session.jsonl"])
}
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
t.Errorf("display sidecar should be removed after delete, got %q", got)
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "[Pasted text #1 · 5 lines]" {
t.Errorf("display sidecar should be retained in trash, got %q", got)
}
}
@@ -160,7 +173,104 @@ func TestDeleteSessionFileNoTitle(t *testing.T) {
t.Fatalf("delete: %v", err)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Error("session file should be deleted")
t.Error("session file should be removed from active sessions")
}
if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "no-title.jsonl", "no-title.jsonl")); err != nil {
t.Fatalf("session file should be in trash: %v", err)
}
}
func TestRestoreTrashedSessionFile(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
ckptDir := filepath.Join(dir, "session.ckpt")
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
t.Fatal(err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
t.Fatalf("restore: %v", err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("session file should be restored: %v", err)
}
if _, err := os.Stat(sessionPath + ".meta"); err != nil {
t.Fatalf("session meta should be restored: %v", err)
}
if _, err := os.Stat(ckptDir); err != nil {
t.Fatalf("session checkpoints should be restored: %v", err)
}
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
t.Fatalf("trash item should be removed after restore, stat err = %v", err)
}
if got := loadSessionTitles(dir)["session.jsonl"]; got != "My Title" {
t.Fatalf("title should survive restore, got %q", got)
}
}
func TestPurgeTrashedSessionFile(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.jsonl")
os.WriteFile(sessionPath, []byte("data"), 0o644)
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
t.Fatal(err)
}
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
t.Fatal(err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
t.Fatalf("purge: %v", err)
}
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
t.Fatalf("trash item should be removed after purge, stat err = %v", err)
}
if _, ok := loadSessionTitles(dir)["session.jsonl"]; ok {
t.Fatal("title should be removed after purge")
}
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
t.Fatalf("display sidecar should be removed after purge, got %q", got)
}
}
func TestListTrashedSessionFilesRejectsSymlinkEscape(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.jsonl")
os.WriteFile(outside, []byte("data"), 0o644)
itemDir := filepath.Join(dir, sessionTrashDir, "outside.jsonl")
if err := os.MkdirAll(itemDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(itemDir, "outside.jsonl")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
got, err := listTrashedSessionFiles(dir)
if err != nil {
t.Fatalf("list trash: %v", err)
}
if len(got) != 0 {
t.Fatalf("trash listing should skip symlink escape, got %v", got)
}
}
+165 -38
View File
@@ -2,8 +2,8 @@ package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"reasonix/internal/agent"
@@ -70,14 +70,18 @@ type AgentView struct {
// SettingsView is the whole Settings panel payload.
type SettingsView struct {
DefaultModel string `json:"defaultModel"`
PlannerModel string `json:"plannerModel"`
Providers []ProviderView `json:"providers"`
Permissions PermissionsView `json:"permissions"`
Sandbox SandboxView `json:"sandbox"`
Network NetworkView `json:"network"`
Agent AgentView `json:"agent"`
ConfigPath string `json:"configPath"`
DefaultModel string `json:"defaultModel"`
PlannerModel string `json:"plannerModel"`
Providers []ProviderView `json:"providers"`
Permissions PermissionsView `json:"permissions"`
Sandbox SandboxView `json:"sandbox"`
Network NetworkView `json:"network"`
Agent AgentView `json:"agent"`
DesktopLanguage string `json:"desktopLanguage"`
DesktopTheme string `json:"desktopTheme"`
DesktopThemeStyle string `json:"desktopThemeStyle"`
CloseBehavior string `json:"closeBehavior"`
ConfigPath string `json:"configPath"`
// ProviderKinds lists the provider implementations the kernel actually
// registered (provider.Kinds()), so the editor's "kind" picker offers only
// kinds that resolve — selecting an unregistered one would fail the rebuild.
@@ -96,7 +100,7 @@ func nonNil(s []string) []string {
// Settings returns the current configuration for the Settings panel.
func (a *App) Settings() SettingsView {
cfg, err := config.Load()
cfg, cfgPath, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return SettingsView{
Providers: []ProviderView{},
@@ -107,9 +111,13 @@ func (a *App) Settings() SettingsView {
Ask: []string{},
Deny: []string{},
},
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
DesktopTheme: "dark",
DesktopThemeStyle: "graphite",
CloseBehavior: "background",
}
}
ctrl := a.activeCtrl()
bash := cfg.Sandbox.Bash
if bash == "" {
bash = "enforce"
@@ -140,10 +148,14 @@ func (a *App) Settings() SettingsView {
Password: cfg.Network.Proxy.Password,
},
},
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
ConfigPath: config.SourcePath(),
ProviderKinds: provider.Kinds(),
Bypass: a.ctrl != nil && a.ctrl.Bypass(),
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
DesktopLanguage: cfg.DesktopLanguage(),
DesktopTheme: cfg.DesktopTheme(),
DesktopThemeStyle: cfg.DesktopThemeStyle(),
CloseBehavior: cfg.DesktopCloseBehavior(),
ConfigPath: cfgPath,
ProviderKinds: nonNil(provider.Kinds()),
Bypass: ctrl != nil && ctrl.Bypass(),
}
for i := range cfg.Providers {
p := &cfg.Providers[i]
@@ -173,11 +185,10 @@ func orDefault(s, def string) string {
// keys are account-level, not per-project: writing them to the global config
// rather than the cwd's reasonix.toml is what lets them survive a workspace switch.
func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
path := config.UserConfigPath()
if path == "" {
return fmt.Errorf("cannot resolve user config directory")
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
cfg := config.LoadForEdit(path)
if err := mutate(cfg); err != nil {
return err
}
@@ -187,6 +198,65 @@ func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
return a.rebuild()
}
func (a *App) applyConfigOnly(mutate func(*config.Config) error) error {
cfg, path, err := a.loadDesktopUserConfigForEdit()
if err != nil {
return err
}
if err := mutate(cfg); err != nil {
return err
}
return cfg.SaveTo(path)
}
func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
userPath := config.UserConfigPath()
if userPath == "" {
return nil, "", fmt.Errorf("cannot resolve user config directory")
}
if _, err := os.Stat(userPath); err == nil {
return config.LoadForEdit(userPath), userPath, nil
}
cfg := config.LoadForEdit(userPath)
legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
return cfg, userPath, nil
}
legacyCfg := config.LoadForEdit(legacyPath)
legacyCfg.ConfigVersion = config.Default().ConfigVersion
return legacyCfg, userPath, nil
}
func (a *App) activeWorkspaceRoot() string {
a.mu.RLock()
defer a.mu.RUnlock()
if tab := a.activeTabLocked(); tab != nil {
return tab.WorkspaceRoot
}
return "."
}
func projectConfigPathForRoot(root string) string {
if strings.TrimSpace(root) == "" || root == "." {
return "reasonix.toml"
}
return filepath.Join(root, "reasonix.toml")
}
func sameConfigPath(a, b string) bool {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
aAbs, aErr := filepath.Abs(a)
bAbs, bErr := filepath.Abs(b)
if aErr == nil && bErr == nil {
return filepath.Clean(aAbs) == filepath.Clean(bAbs)
}
return filepath.Clean(a) == filepath.Clean(b)
}
// rebuild tears down the controller and rebuilds it from the (just-changed)
// config, carrying the conversation forward. It keeps the active model if it
// still resolves; otherwise it falls back to the new default. Mirrors SetModel.
@@ -194,15 +264,20 @@ func (a *App) rebuild() error {
if a.ctx == nil {
return nil
}
tab := a.activeTab()
if tab == nil {
return fmt.Errorf("no active tab")
}
var carried []provider.Message
prevPath := ""
if a.ctrl != nil {
prevPath = a.ctrl.SessionPath()
_ = a.ctrl.Snapshot()
carried = a.ctrl.History()
if tab.Ctrl != nil {
prevPath = tab.Ctrl.SessionPath()
_ = tab.Ctrl.Snapshot()
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
model := a.model
if cfg, err := config.Load(); err == nil {
model := tab.model
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
if _, ok := cfg.ResolveModel(model); !ok {
model = cfg.DefaultModel
if e, ok := cfg.ResolveModel(model); ok {
@@ -210,20 +285,31 @@ func (a *App) rebuild() error {
}
}
}
ctrl, err := boot.Build(a.ctx, boot.Options{Model: model, RequireKey: false, Sink: a.sink, Stderr: io.Discard})
ctrl, err := boot.Build(a.bootContext(), boot.Options{
Model: model, RequireKey: false,
Sink: tab.sink,
WorkspaceRoot: tab.WorkspaceRoot,
EffortOverride: cloneStringPtr(tab.effort),
})
if err != nil {
a.startupErr = err.Error()
a.mu.Lock()
tab.StartupErr = err.Error()
tab.Ready = true
a.mu.Unlock()
a.emitReady(a.ctx)
return err
}
old := a.ctrl
a.ctrl = ctrl
a.model = model
a.label = ctrl.Label()
a.startupErr = ""
if old != nil {
old.Close()
}
a.mu.Lock()
tab.Ctrl = ctrl
tab.model = model
tab.Label = ctrl.Label()
tab.StartupErr = ""
tab.Ready = true
a.saveTabsLocked()
a.mu.Unlock()
a.emitReady(a.ctx)
ctrl.EnableInteractiveApproval()
applyTabModeToController(ctrl, tab.mode)
path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
if len(carried) > 0 {
carried = withFreshSystemPrompt(carried, systemPromptFrom(ctrl.History()))
@@ -264,8 +350,12 @@ func withFreshSystemPrompt(messages []provider.Message, system string) []provide
// SetDefaultModel sets the config default and switches the live model to it.
func (a *App) SetDefaultModel(ref string) error {
prev := a.model
a.model = ref
tab := a.activeTab()
if tab == nil {
return fmt.Errorf("no active tab")
}
prev := tab.model
tab.model = ref
if err := a.applyConfigChange(func(c *config.Config) error {
if _, ok := c.ResolveModel(ref); !ok {
return fmt.Errorf("unknown model %q", ref)
@@ -273,7 +363,7 @@ func (a *App) SetDefaultModel(ref string) error {
c.DefaultModel = ref
return nil
}); err != nil {
a.model = prev
tab.model = prev
return err
}
return nil
@@ -376,6 +466,43 @@ func (a *App) SetNetwork(n NetworkView) error {
})
}
// SetCloseBehavior updates desktop-only window close behavior without rebuilding
// the active controller. It must stay out of provider-visible prompt/request data.
func (a *App) SetCloseBehavior(mode string) error {
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCloseBehavior(mode) })
}
// SetDesktopLanguage updates only the desktop UI language. It deliberately does
// not touch config.language, which the CLI/model-facing runtime uses.
func (a *App) SetDesktopLanguage(lang string) error {
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopLanguage(lang) })
}
// SetDesktopAppearance updates only desktop theme preferences. It does not
// rebuild the active controller and must stay out of provider-visible requests.
func (a *App) SetDesktopAppearance(theme, style string) error {
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopAppearance(theme, style) })
}
// MigrateDesktopPreferences imports old browser-local desktop preferences into
// the user config once. Existing [desktop] values win so stale localStorage never
// overwrites an explicit config edit.
func (a *App) MigrateDesktopPreferences(language, theme, style string) error {
return a.applyConfigOnly(func(c *config.Config) error {
if strings.TrimSpace(c.Desktop.Language) == "" {
if err := c.SetDesktopLanguage(language); err != nil {
return err
}
}
if strings.TrimSpace(c.Desktop.Theme) == "" && strings.TrimSpace(c.Desktop.ThemeStyle) == "" {
if err := c.SetDesktopAppearance(theme, style); err != nil {
return err
}
}
return nil
})
}
// SetAgentParams updates sampling temperature, the optional max-steps guard, and
// the base system prompt.
func (a *App) SetAgentParams(temperature float64, maxSteps int, systemPrompt string) error {
+12
View File
@@ -0,0 +1,12 @@
package main
import "github.com/wailsapp/wails/v2/pkg/options"
func singleInstanceLock(app *App) *options.SingleInstanceLock {
return &options.SingleInstanceLock{
UniqueId: singleInstanceID,
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
app.secondInstanceLaunch()
},
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"testing"
"github.com/wailsapp/wails/v2/pkg/options"
)
func TestSingleInstanceLockRestoresExistingInstance(t *testing.T) {
app := NewApp()
lock := singleInstanceLock(app)
if lock == nil {
t.Fatal("singleInstanceLock returned nil")
}
if lock.UniqueId != singleInstanceID {
t.Fatalf("UniqueId = %q, want %q", lock.UniqueId, singleInstanceID)
}
if lock.OnSecondInstanceLaunch == nil {
t.Fatal("OnSecondInstanceLaunch should restore the existing window")
}
lock.OnSecondInstanceLaunch(options.SecondInstanceData{})
}
+42
View File
@@ -6,6 +6,8 @@ import (
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/skill"
)
func TestNormalizeSkillPathDirectoryLayout(t *testing.T) {
@@ -26,6 +28,7 @@ func TestSkillRootsViewCountsProjectSkills(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
project := t.TempDir()
root := filepath.Join(project, ".reasonix", "skills")
if err := os.MkdirAll(root, 0o755); err != nil {
@@ -63,6 +66,7 @@ func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
project := t.TempDir()
root := filepath.Join(home, "custom-skills")
if err := os.MkdirAll(root, 0o755); err != nil {
@@ -104,6 +108,44 @@ func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
t.Fatalf("custom skill root %q not found in %+v", root, roots)
}
func TestCapabilitiesIncludesDisabledSkills(t *testing.T) {
a := NewApp()
a.setTestCtrl(control.New(control.Options{
Skills: []skill.Skill{
{Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
},
AllSkills: []skill.Skill{
{Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
{Name: "review", Description: "disabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
},
}), "")
defer a.activeCtrl().Close()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
cfgPath := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfgPath, []byte("[skills]\ndisabled_skills = [\"review\"]\n"), 0o644); err != nil {
t.Fatal(err)
}
view := a.Capabilities()
states := map[string]bool{}
for _, sk := range view.Skills {
states[sk.Name] = sk.Enabled
}
if states["explore"] != true {
t.Fatalf("explore should be enabled in capabilities: %+v", view.Skills)
}
enabled, ok := states["review"]
if !ok || enabled {
t.Fatalf("review should be disabled but present in capabilities: %+v", view.Skills)
}
}
func realTestPath(path string) string {
if p, err := filepath.EvalSymlinks(path); err == nil {
path = p
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/control"
)
func testTab(id, root string) *WorkspaceTab {
return &WorkspaceTab{
ID: id,
Scope: "project",
WorkspaceRoot: root,
Ready: true,
Ctrl: control.New(control.Options{Label: id}),
model: "deepseek-flash/deepseek-v4-flash",
mode: "normal",
disabledMCP: map[string]ServerView{},
}
}
func TestSetEffortForTabIsTabLocal(t *testing.T) {
isolateDesktopUserDirs(t)
rootA := t.TempDir()
rootB := t.TempDir()
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tabA := testTab("a", rootA)
tabB := testTab("b", rootB)
tabA.sink = &tabEventSink{tabID: tabA.ID, app: app}
tabB.sink = &tabEventSink{tabID: tabB.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tabA.ID: tabA, tabB.ID: tabB}
app.tabOrder = []string{tabA.ID, tabB.ID}
app.activeTabID = tabA.ID
defer func() {
for _, tab := range app.tabs {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
}
}()
if err := app.SetEffortForTab(tabA.ID, "max"); err != nil {
t.Fatalf("SetEffortForTab: %v", err)
}
if got := app.EffortForTab(tabA.ID).Current; got != "max" {
t.Fatalf("tab A effort = %q, want max", got)
}
if got := app.EffortForTab(tabB.ID).Current; got != "auto" {
t.Fatalf("tab B effort = %q, want auto", got)
}
if tabB.effort != nil {
t.Fatalf("tab B stored effort = %q, want nil", *tabB.effort)
}
body, err := os.ReadFile(userConfigPathForTest())
if err == nil && strings.Contains(string(body), `effort`) {
t.Fatalf("tab-local effort should not write provider config:\n%s", body)
}
}
func TestEffortForTabResolvesProjectProviderConfig(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
configBody := `default_model = "project-provider/deepseek-v4-flash"
[[providers]]
name = "project-provider"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "PROJECT_API_KEY"
effort = "max"
`
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(configBody), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
tab := testTab("project", projectRoot)
tab.model = "project-provider/deepseek-v4-flash"
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.activeTabID = tab.ID
defer tab.Ctrl.Close()
got := app.EffortForTab(tab.ID)
if !got.Supported || got.Current != "max" {
t.Fatalf("EffortForTab project config = %+v, want supported max", got)
}
}
func TestSaveTabsPersistsModelAndEffort(t *testing.T) {
isolateDesktopUserDirs(t)
effort := "max"
app := NewApp()
tab := testTab("a", t.TempDir())
tab.effort = &effort
tab.model = "deepseek/deepseek-v4-pro"
tab.mode = "plan"
tab.Ctrl.SetPlanMode(true)
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
got := loadTabsFile()
if len(got.Tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
}
if got.Tabs[0].Model != tab.model {
t.Fatalf("saved model = %q, want %q", got.Tabs[0].Model, tab.model)
}
if got.Tabs[0].Effort == nil || *got.Tabs[0].Effort != effort {
t.Fatalf("saved effort = %#v, want %q", got.Tabs[0].Effort, effort)
}
if got.Tabs[0].Mode != "plan" {
t.Fatalf("saved mode = %q, want plan", got.Tabs[0].Mode)
}
}
func TestSaveTabsDoesNotPersistYoloMode(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
tab := testTab("a", t.TempDir())
tab.mode = "yolo"
tab.Ctrl.SetBypass(true)
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
got := loadTabsFile()
if len(got.Tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
}
if got.Tabs[0].Mode != "" {
t.Fatalf("saved yolo mode = %q, want empty", got.Tabs[0].Mode)
}
}
func userConfigPathForTest() string {
if dir, err := os.UserConfigDir(); err == nil {
return dir + "/reasonix/reasonix.toml"
}
return ""
}
+2015
View File
File diff suppressed because it is too large. Load diff
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"strings"
"testing"
)
func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
t.Helper()
isolateDesktopUserDirs(t)
tabs := make(map[string]*WorkspaceTab, len(ids))
for _, id := range ids {
tabs[id] = &WorkspaceTab{
ID: id,
Scope: "global",
TopicID: "topic_" + id,
TopicTitle: id,
Ready: true,
disabledMCP: map[string]ServerView{},
}
}
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
}
func tabIDs(tabs []TabMeta) []string {
ids := make([]string, 0, len(tabs))
for _, tab := range tabs {
ids = append(ids, tab.ID)
}
return ids
}
func assertTabIDs(t *testing.T, got []TabMeta, want ...string) {
t.Helper()
gotIDs := tabIDs(got)
if len(gotIDs) != len(want) {
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
}
for i := range want {
if gotIDs[i] != want[i] {
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
}
}
}
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
if err := app.SetActiveTab("c"); err != nil {
t.Fatalf("SetActiveTab: %v", err)
}
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
if got := app.activeTabID; got != "c" {
t.Fatalf("active tab = %q, want c", got)
}
}
func TestReorderTabsPersistsSubmittedOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
if err := app.ReorderTabs([]string{"c", "a", "b"}); err != nil {
t.Fatalf("ReorderTabs: %v", err)
}
assertTabIDs(t, app.ListTabs(), "c", "a", "b")
if got := app.activeTabID; got != "a" {
t.Fatalf("active tab = %q, want a", got)
}
}
func TestCloseActiveTabChoosesNeighborByOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
if err := app.CloseTab("b"); err != nil {
t.Fatalf("CloseTab(b): %v", err)
}
assertTabIDs(t, app.ListTabs(), "a", "c")
if got := app.activeTabID; got != "c" {
t.Fatalf("active tab after closing middle = %q, want c", got)
}
if err := app.CloseTab("c"); err != nil {
t.Fatalf("CloseTab(c): %v", err)
}
assertTabIDs(t, app.ListTabs(), "a")
if got := app.activeTabID; got != "a" {
t.Fatalf("active tab after closing last = %q, want a", got)
}
}
func TestReorderTabsRejectsInvalidOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
for name, order := range map[string][]string{
"missing": {"a", "b"},
"unknown": {"a", "b", "missing"},
"duplicate": {"a", "b", "b"},
} {
t.Run(name, func(t *testing.T) {
if err := app.ReorderTabs(order); err == nil {
t.Fatalf("ReorderTabs(%v) succeeded, want error", order)
}
})
}
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
}
func TestNewUniqueTabIDLockedUsesFreshRandomID(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
app.mu.Lock()
got := app.newUniqueTabIDLocked()
app.mu.Unlock()
if _, exists := app.tabs[got]; exists {
t.Fatalf("newUniqueTabIDLocked returned existing id %q", got)
}
if !strings.HasPrefix(got, "tab_") {
t.Fatalf("tab id = %q, want tab_ prefix", got)
}
if len(got) != len("tab_")+32 {
t.Fatalf("tab id = %q, length %d, want 36", got, len(got))
}
}
func TestRestoredTabIDLockedReplacesEmptyAndDuplicateIDs(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
app.mu.Lock()
kept := app.restoredTabIDLocked("d")
duplicate := app.restoredTabIDLocked("a")
empty := app.restoredTabIDLocked(" ")
app.mu.Unlock()
if kept != "d" {
t.Fatalf("restored unique id = %q, want d", kept)
}
for name, got := range map[string]string{"duplicate": duplicate, "empty": empty} {
if _, exists := app.tabs[got]; exists {
t.Fatalf("%s restored id %q already exists", name, got)
}
if !strings.HasPrefix(got, "tab_") {
t.Fatalf("%s restored id = %q, want tab_ prefix", name, got)
}
}
}
+599
View File
@@ -0,0 +1,599 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
)
func writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitle,
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
return path
}
func TestDeleteTopicKeepsSessionHistory(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_keep_history"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSession(t, dir, "keep.jsonl", topicID, "Keep history", projectRoot)
if err := NewApp().DeleteTopic(topicID); err != nil {
t.Fatalf("delete topic: %v", err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("delete topic should keep session history: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed, got %q", got)
}
}
func TestRenameProjectUpdatesSidebarTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := NewApp().RenameProject(projectRoot, "Client API"); err != nil {
t.Fatalf("rename project: %v", err)
}
nodes := NewApp().ListProjectTree()
if len(nodes) != 1 {
t.Fatalf("project tree len = %d, want 1", len(nodes))
}
if got := nodes[0].Label; got != "Client API" {
t.Fatalf("project label = %q, want Client API", got)
}
if err := NewApp().RenameProject(projectRoot, ""); err != nil {
t.Fatalf("clear project title: %v", err)
}
nodes = NewApp().ListProjectTree()
if got, want := nodes[0].Label, filepath.Base(projectRoot); got != want {
t.Fatalf("cleared project label = %q, want %q", got, want)
}
}
func TestListWorkspacesUsesProjectRegistryTitles(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Client API"); err != nil {
t.Fatalf("add project: %v", err)
}
workspaces := NewApp().ListWorkspaces()
if len(workspaces) != 1 {
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
}
if got := workspaces[0].Path; got != projectRoot {
t.Fatalf("workspace path = %q, want %q", got, projectRoot)
}
if got := workspaces[0].Name; got != "Client API" {
t.Fatalf("workspace name = %q, want Client API", got)
}
}
func TestListWorkspacesMigratesLegacyWorkspaceList(t *testing.T) {
isolateDesktopUserDirs(t)
legacyRoot := t.TempDir()
rememberWorkspace(legacyRoot)
workspaces := NewApp().ListWorkspaces()
if len(workspaces) != 1 {
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
}
if got := workspaces[0].Path; got != legacyRoot {
t.Fatalf("workspace path = %q, want %q", got, legacyRoot)
}
projects := loadProjectsFile().Projects
if len(projects) != 1 || projects[0].Root != legacyRoot {
t.Fatalf("legacy workspace was not migrated into projects: %+v", projects)
}
}
func TestReorderProjectsPersistsSidebarAndWorkspaceOrder(t *testing.T) {
isolateDesktopUserDirs(t)
first := t.TempDir()
second := t.TempDir()
third := t.TempDir()
if err := addProject(first, "First"); err != nil {
t.Fatalf("add first project: %v", err)
}
if err := addProject(second, "Second"); err != nil {
t.Fatalf("add second project: %v", err)
}
if err := addProject(third, "Third"); err != nil {
t.Fatalf("add third project: %v", err)
}
app := NewApp()
if err := app.ReorderProjects([]string{third, first, second}); err != nil {
t.Fatalf("ReorderProjects: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 3 {
t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
}
if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
t.Fatalf("project tree order = %v, want %v", got, []string{third, first, second})
}
workspaces := app.ListWorkspaces()
if len(workspaces) != 3 {
t.Fatalf("workspaces len = %d, want 3: %+v", len(workspaces), workspaces)
}
if got := []string{workspaces[0].Path, workspaces[1].Path, workspaces[2].Path}; got[0] != third || got[1] != first || got[2] != second {
t.Fatalf("workspace order = %v, want %v", got, []string{third, first, second})
}
}
func TestReorderProjectsRejectsInvalidOrder(t *testing.T) {
isolateDesktopUserDirs(t)
first := t.TempDir()
second := t.TempDir()
if err := addProject(first, "First"); err != nil {
t.Fatalf("add first project: %v", err)
}
if err := addProject(second, "Second"); err != nil {
t.Fatalf("add second project: %v", err)
}
app := NewApp()
for name, order := range map[string][]string{
"missing": {first},
"unknown": {first, filepath.Join(t.TempDir(), "missing")},
"duplicate": {first, first},
} {
t.Run(name, func(t *testing.T) {
if err := app.ReorderProjects(order); err == nil {
t.Fatalf("ReorderProjects(%v) succeeded, want error", order)
}
})
}
nodes := app.ListProjectTree()
if got := []string{nodes[0].Root, nodes[1].Root}; got[0] != first || got[1] != second {
t.Fatalf("project tree order changed after invalid reorder: %v", got)
}
}
func TestRemoveWorkspaceUsesSharedProjectRegistryForCurrentProject(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Current Project"); err != nil {
t.Fatalf("add project: %v", err)
}
app := NewApp()
tab := app.createTabEntryWithID("project", projectRoot, "topic_current", "tab_current")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
if err := app.RemoveWorkspace(projectRoot); err != nil {
t.Fatalf("remove current project: %v", err)
}
if got := app.ListWorkspaces(); len(got) != 0 {
t.Fatalf("workspaces after remove = %+v, want empty", got)
}
if got := app.ListProjectTree(); len(got) != 0 {
t.Fatalf("project tree after remove = %+v, want empty", got)
}
}
func TestRestoredProjectTabUsesStoredTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_stored_title"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "你是谁"); err != nil {
t.Fatalf("set topic title: %v", err)
}
app := NewApp()
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(tabs))
}
if got := tabs[0].TopicTitle; got != "你是谁" {
t.Fatalf("tab title = %q, want 你是谁", got)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
}
if got := nodes[0].Children[0].Label; got != tabs[0].TopicTitle {
t.Fatalf("tree title = %q, want same as tab title %q", got, tabs[0].TopicTitle)
}
}
func TestUntitledProjectTopicUsesSameFallbackEverywhere(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_without_title"
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{
Root: projectRoot,
Topics: []string{topicID},
}}}); err != nil {
t.Fatalf("save projects: %v", err)
}
app := NewApp()
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("tabs len = %d, want 1", len(tabs))
}
if got := tabs[0].TopicTitle; got != defaultTopicTitle {
t.Fatalf("tab title = %q, want %q", got, defaultTopicTitle)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
}
if got := nodes[0].Children[0].Label; got != defaultTopicTitle {
t.Fatalf("tree title = %q, want %q", got, defaultTopicTitle)
}
}
func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if got := topic.Title; got != defaultTopicTitle {
t.Fatalf("topic title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
t.Fatalf("title source = %q, want auto", got)
}
}
func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
first, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create first topic: %v", err)
}
second, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create second topic: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 2 {
t.Fatalf("project tree = %#v, want one project with two topics", nodes)
}
if got := nodes[0].Children[0].TopicID; got != second.ID {
t.Fatalf("first visible topic = %q, want newest %q", got, second.ID)
}
if got := nodes[0].Children[1].TopicID; got != first.ID {
t.Fatalf("second visible topic = %q, want older %q", got, first.ID)
}
}
func TestCreateGlobalTopicAppearsFirstInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
first, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create first global topic: %v", err)
}
second, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create second global topic: %v", err)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 2 {
t.Fatalf("project tree = %#v, want Global with two topics", nodes)
}
if got := nodes[0].Children[0].TopicID; got != second.ID {
t.Fatalf("first visible global topic = %q, want newest %q", got, second.ID)
}
if got := nodes[0].Children[1].TopicID; got != first.ID {
t.Fatalf("second visible global topic = %q, want older %q", got, first.ID)
}
}
func TestSwitchWorkspaceRegistersDefaultTopicInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
if got, err := app.SwitchWorkspace(projectRoot); err != nil {
t.Fatalf("SwitchWorkspace: %v", err)
} else if got != projectRoot {
t.Fatalf("SwitchWorkspace root = %q, want %q", got, projectRoot)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 {
t.Fatalf("project tree len = %d, want 1: %+v", len(nodes), nodes)
}
if got := nodes[0].Root; got != projectRoot {
t.Fatalf("project root = %q, want %q", got, projectRoot)
}
if len(nodes[0].Children) != 1 {
t.Fatalf("project children len = %d, want 1: %+v", len(nodes[0].Children), nodes[0].Children)
}
child := nodes[0].Children[0]
if got := child.Label; got != defaultTopicTitle {
t.Fatalf("default topic label = %q, want %q", got, defaultTopicTitle)
}
if strings.TrimSpace(child.TopicID) == "" {
t.Fatalf("default topic ID should be persisted in the project tree: %+v", child)
}
tabs := app.ListTabs()
if len(tabs) != 1 || tabs[0].TopicID != child.TopicID {
t.Fatalf("opened tab should use the persisted topic, tabs=%+v child=%+v", tabs, child)
}
}
func TestRenameTopicLocksTitleManual(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
t.Fatalf("rename topic: %v", err)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
t.Fatalf("stored title = %q, want 手动标题", got)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
t.Fatalf("title source = %q, want manual", got)
}
}
func TestRenameTopicUpdatesOpenTabMeta(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "旧标题")
if err != nil {
t.Fatalf("create topic: %v", err)
}
tab, err := app.OpenProjectTab(projectRoot, topic.ID)
if err != nil {
t.Fatalf("open project tab: %v", err)
}
if tab.TopicTitle != "旧标题" {
t.Fatalf("opened tab title = %q, want 旧标题", tab.TopicTitle)
}
if err := app.RenameTopic(topic.ID, "新标题"); err != nil {
t.Fatalf("rename topic: %v", err)
}
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("tabs len = %d, want 1: %+v", len(tabs), tabs)
}
if got := tabs[0].TopicTitle; got != "新标题" {
t.Fatalf("open tab title = %q, want 新标题", got)
}
}
func TestAutoTitleTopicFromFirstUserMessage(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
if !updated {
t.Fatal("auto title should update")
}
if title != "讲讲这个代码库的架构" {
t.Fatalf("generated title = %q", title)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != title {
t.Fatalf("stored title = %q, want %q", got, title)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
t.Fatalf("title source = %q, want auto", got)
}
}
func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
t.Fatalf("rename topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
t.Fatalf("manual title should not auto-update, title=%q updated=%v", title, updated)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
t.Fatalf("stored title = %q, want 手动标题", got)
}
}
func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_trash_history"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Trash history"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSession(t, dir, "trash-me.jsonl", topicID, "Trash history", projectRoot)
if err := NewApp().TrashTopic(topicID); err != nil {
t.Fatalf("trash topic: %v", err)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("topic session should be removed from active history, stat err = %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "trash-me.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("topic session should be moved to trash: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed, got %q", got)
}
}
func TestTrashTopicMovesOpenSessionToTrash(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_open_trash"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Open trash"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := filepath.Join(dir, "open-trash.jsonl")
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Open trash",
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
openTab := &WorkspaceTab{
ID: "tab_open",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Open trash",
Ctrl: controllerWithContent(t, sessionPath),
Ready: true,
disabledMCP: map[string]ServerView{},
}
otherTab := &WorkspaceTab{
ID: "tab_other",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: "topic_keep",
TopicTitle: "Keep",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"tab_open": openTab, "tab_other": otherTab},
tabOrder: []string{"tab_open", "tab_other"},
activeTabID: "tab_open",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("trash topic: %v", err)
}
if _, ok := app.tabs["tab_open"]; ok {
t.Fatalf("open tab for trashed topic should be removed")
}
if got := app.activeTabID; got != "tab_other" {
t.Fatalf("active tab = %q, want tab_other", got)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("open topic session should be removed from active history, stat err = %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, "open-trash.jsonl", "open-trash.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("open topic session should be moved to trash: %v", err)
}
trashed := app.ListTrashedSessions()
if len(trashed) != 1 || trashed[0].Path != trashPath {
t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed, got %q", got)
}
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/internal/config"
)
// DesktopWindowState captures the window geometry to restore across launches.
type DesktopWindowState struct {
Width int `json:"width"`
Height int `json:"height"`
X int `json:"x"`
Y int `json:"y"`
Maximised bool `json:"maximised"`
}
func windowStatePath() string {
return filepath.Join(config.MemoryUserDir(), "desktop-window.json")
}
// loadWindowState reads the saved window geometry. The second return value is
// false when no saved state exists (first launch, missing file, corrupt JSON).
// Callers must not restore position when ok is false — zero values are not a
// valid window origin.
func loadWindowState() (DesktopWindowState, bool) {
path := windowStatePath()
data, err := os.ReadFile(path)
if err != nil {
return DesktopWindowState{}, false
}
var s DesktopWindowState
if err := json.Unmarshal(data, &s); err != nil {
return DesktopWindowState{}, false
}
if s.Width < 400 {
s.Width = 0
}
if s.Height < 300 {
s.Height = 0
}
// Migration guard: the previous version saved (0,0) for first-launch zero
// values. Treat an all-zero valid file the same as missing — let domReady
// center the window instead of parking it at the screen origin.
if s.Width == 0 && s.Height == 0 && s.X == 0 && s.Y == 0 {
return DesktopWindowState{}, false
}
return s, true
}
// SaveWindowState is the bound method the frontend calls to persist the current
// window geometry before quit and periodically during use.
func (a *App) SaveWindowState(state DesktopWindowState) error {
path := windowStatePath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
// saveWindowStateSync saves the current window geometry from the Go side (called
// during shutdown so the last-known state is persisted even if the frontend's
// beforeunload promise hasn't resolved).
func (a *App) saveWindowStateSync() {
if a.ctx == nil {
return
}
w, h := runtime.WindowGetSize(a.ctx)
x, y := runtime.WindowGetPosition(a.ctx)
max := runtime.WindowIsMaximised(a.ctx)
_ = a.SaveWindowState(DesktopWindowState{
Width: w,
Height: h,
X: x,
Y: y,
Maximised: max,
})
}
+9 -2
View File
@@ -78,7 +78,11 @@ type wireUsage struct {
// hit-rate Σhit/Σ(hit+miss), steadier than the single-turn CacheHitTokens.
SessionCacheHitTokens int `json:"sessionCacheHitTokens"`
SessionCacheMissTokens int `json:"sessionCacheMissTokens"`
CostUSD float64 `json:"costUsd,omitempty"`
Cost float64 `json:"cost,omitempty"`
Currency string `json:"currency,omitempty"`
// CostUSD is kept for older frontend/status consumers. It mirrors Cost and
// does not imply USD.
CostUSD float64 `json:"costUsd,omitempty"`
}
type wireCacheDiagnostics struct {
@@ -161,7 +165,10 @@ func toWire(e event.Event) wireEvent {
w.Usage.CacheDiagnostics = toWireCacheDiagnostics(e.CacheDiagnostics)
}
if e.Pricing != nil {
w.Usage.CostUSD = e.Pricing.Cost(u)
cost := e.Pricing.Cost(u)
w.Usage.Cost = cost
w.Usage.Currency = e.Pricing.Symbol()
w.Usage.CostUSD = cost
}
}
case event.ApprovalRequest:
+5 -2
View File
@@ -106,8 +106,11 @@ func TestToWireUsageWithPricing(t *testing.T) {
Pricing: &provider.Pricing{CacheHit: 1.0, Input: 2.0, Output: 10.0},
}
w := toWire(e)
if w.Usage == nil || w.Usage.CostUSD != 1.0 {
t.Errorf("cost = %f, want 1.0", w.Usage.CostUSD)
if w.Usage == nil || w.Usage.Cost != 1.0 || w.Usage.CostUSD != 1.0 {
t.Errorf("cost = %+v, want cost and compat costUsd of 1.0", w.Usage)
}
if w.Usage.Currency != "¥" {
t.Errorf("currency = %q, want ¥", w.Usage.Currency)
}
}
+29 -1
View File
@@ -44,7 +44,6 @@ func saveWorkspace(dir string) {
return
}
_ = os.WriteFile(p, []byte(dir), 0o644)
rememberWorkspace(dir)
}
// loadWorkspace returns the remembered working folder, or "" if none.
@@ -112,6 +111,35 @@ func rememberWorkspace(dir string) {
}
}
func forgetWorkspace(dir string) {
dir = strings.TrimSpace(dir)
if dir == "" {
return
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
paths := make([]string, 0, len(loadWorkspaces()))
for _, path := range loadWorkspaces() {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
if path != dir {
paths = append(paths, path)
}
}
p := workspaceListPath()
if p == "" {
return
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return
}
if b, err := json.MarshalIndent(paths, "", " "); err == nil {
_ = os.WriteFile(p, b, 0o644)
}
}
// ensureWorkspace establishes a writable working directory at startup: the
// remembered folder if it's still a directory, else the home directory when the
// current cwd isn't writable (the Finder/`open` "/" case). A writable cwd with no
+2 -3
View File
@@ -2,7 +2,6 @@ package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"sort"
@@ -25,7 +24,7 @@ type workspaceChangeAccumulator struct {
func (a *App) WorkspaceChanges() WorkspaceChangesView {
out := WorkspaceChangesView{GitAvailable: true}
base, err := os.Getwd()
base, err := a.activeWorkspaceBase()
if err != nil {
out.GitAvailable = false
out.GitErr = err.Error()
@@ -45,7 +44,7 @@ func (a *App) WorkspaceChanges() WorkspaceChangesView {
}
a.mu.RLock()
ctrl := a.ctrl
ctrl := a.activeCtrlLocked()
a.mu.RUnlock()
if ctrl != nil {
for _, meta := range ctrl.Checkpoints() {
+11 -14
View File
@@ -28,14 +28,12 @@ func TestWorkspaceStatePath(t *testing.T) {
// --- saveWorkspace / loadWorkspace round-trip ---
func TestSaveLoadWorkspaceRoundTrip(t *testing.T) {
// workspaceStatePath() lives under config.MemoryUserDir(), which resolves via
// os.UserConfigDir() — rooted at HOME. Point HOME at a temp dir so the path
// resolves to a real, writable location and the save→load round-trip actually
// exercises persistence instead of silently no-opping when no config dir
// happens to exist in the environment.
t.Setenv("HOME", t.TempDir())
// workspaceStatePath() resolves via os.UserConfigDir() (HOME on unix,
// %AppData% on Windows); isolate both so the round-trip exercises real
// persistence instead of no-opping or leaking into the dev config dir.
isolateDesktopUserDirs(t)
if workspaceStatePath() == "" {
t.Fatal("workspaceStatePath() is empty after pointing HOME at a temp dir")
t.Fatal("workspaceStatePath() is empty after isolating the user config dir")
}
dir := t.TempDir()
@@ -45,8 +43,8 @@ func TestSaveLoadWorkspaceRoundTrip(t *testing.T) {
}
}
func TestSaveWorkspaceRemembersRecentWorkspaces(t *testing.T) {
t.Setenv("HOME", t.TempDir())
func TestSaveWorkspaceOnlyRemembersLastWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
first := t.TempDir()
second := t.TempDir()
@@ -54,12 +52,11 @@ func TestSaveWorkspaceRemembersRecentWorkspaces(t *testing.T) {
saveWorkspace(second)
saveWorkspace(first)
got := loadWorkspaces()
if len(got) < 2 {
t.Fatalf("loadWorkspaces len = %d, want at least 2", len(got))
if got := loadWorkspace(); got != first {
t.Fatalf("loadWorkspace = %q, want %q", got, first)
}
if got[0] != first || got[1] != second {
t.Fatalf("loadWorkspaces = %v, want first two %q, %q", got, first, second)
if got := loadWorkspaces(); len(got) != 0 {
t.Fatalf("saveWorkspace should not maintain legacy workspace list, got %v", got)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+4
View File
@@ -92,6 +92,10 @@ func SaveBranchMeta(sessionPath string, m BranchMeta) error {
return saveBranchMeta(sessionPath, m, true)
}
func SaveBranchMetaPreserveUpdated(sessionPath string, m BranchMeta) error {
return saveBranchMeta(sessionPath, m, false)
}
func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error {
metaPath := BranchMetaPath(sessionPath)
if metaPath == "" {
+12 -2
View File
@@ -56,6 +56,9 @@ type Options struct {
MaxSteps int
RequireKey bool
Sink event.Sink
// EffortOverride is a session-local reasoning effort override. Nil means use
// the resolved provider config; a non-nil empty string means provider default.
EffortOverride *string
// Stderr is the writer for diagnostic warnings and plugin subprocess
// stderr output. When nil, defaults to os.Stderr. Set to io.Discard
// during model switch inside a bubbletea session to prevent any output
@@ -84,8 +87,9 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
root = wd
}
}
// One-time import of a v0.x (~/.reasonix/config.json) install — runs before
// Load so the freshly written config + ~/.env are picked up this same boot.
// One-time import of v1/v0.5 legacy config — runs before Load so the freshly
// written config + ~/.env are picked up this same boot. CLI Run also calls this
// before config-only commands; this call stays as the shared frontend fallback.
migrated, migErr := config.MigrateLegacyIfNeeded()
cfg, err := config.LoadForRoot(root)
if err != nil {
@@ -99,6 +103,12 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
if !ok {
return nil, fmt.Errorf("%w %q (configured: %s); note: defining [[providers]] replaces the built-in presets, so add a [[providers]] entry for it or use a configured name, or run `reasonix setup` to reconfigure", ErrUnknownModel, modelName, providerNames(cfg))
}
if opts.EffortOverride != nil {
entry.Effort = *opts.EffortOverride
if entry.Kind == "anthropic" && strings.TrimSpace(entry.Effort) != "" && strings.TrimSpace(entry.Thinking) == "" {
entry.Thinking = "adaptive"
}
}
if opts.RequireKey {
if err := cfg.Validate(modelName); err != nil {
return nil, err
+12
View File
@@ -31,6 +31,18 @@ func newTestChatTUI() chatTUI {
}
}
func TestCacheRateLabelKeepsTwoDecimals(t *testing.T) {
if got := cacheRateLabel("turn hit %s", 998, 1000); got != "turn hit 99.80%" {
t.Fatalf("cacheRateLabel = %q, want turn hit 99.80%%", got)
}
if got := cacheRateLabel("avg %s", 1, 3); got != "avg 33.33%" {
t.Fatalf("cacheRateLabel = %q, want avg 33.33%%", got)
}
if got := cacheRateLabel("avg %s", 1, 0); got != "" {
t.Fatalf("cacheRateLabel with zero denominator = %q, want empty", got)
}
}
// TestIngestSeparatesReasoningFromAnswer proves the thinking marker plus its live
// text appear as reasoning streams, collapse to a "thought for Ns" summary (the
// streamed text removed) when the answer begins, and the answer commits as its
+10 -5
View File
@@ -1857,8 +1857,15 @@ func (m chatTUI) contextTag() string {
}
}
func cacheRateLabel(format string, hit, denom int) string {
if denom <= 0 {
return ""
}
return fmt.Sprintf(format, fmt.Sprintf("%.2f%%", float64(hit)*100/float64(denom)))
}
// cacheTag renders both prompt cache-hit rates for the status line —
// "cache 88% · avg 78%": the single-turn rate (latest turn, the higher/steeper
// "turn hit 88.00% · avg 78.00%": the single-turn rate (latest turn, the higher/steeper
// number on a non-compacting DeepSeek session) and the session-aggregate rate
// Σhit/Σ(hit+miss) (the steadier, cost-oriented number that matches the legacy
// dashboard). "" before any cache tokens have been reported.
@@ -1869,13 +1876,11 @@ func (m chatTUI) cacheTag() string {
if d == 0 {
d = u.PromptTokens
}
if d > 0 {
now = fmt.Sprintf("cache %d%%", u.CacheHitTokens*100/d)
}
now = cacheRateLabel(i18n.M.ChatStatusCacheNowFmt, u.CacheHitTokens, d)
}
avg := ""
if hit, miss := m.ctrl.SessionCache(); hit+miss > 0 {
avg = fmt.Sprintf("avg %d%%", hit*100/(hit+miss))
avg = cacheRateLabel(i18n.M.ChatStatusCacheAvgFmt, hit, hit+miss)
}
switch {
case now != "" && avg != "":
+23 -1
View File
@@ -39,6 +39,13 @@ func Run(args []string, version string) int {
// welcome banner) come through localized. Env-only first; if a config
// exists and pins a language, that wins.
i18n.DetectLanguage("")
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
if shouldMigrateLegacyConfigForCLI(cmd) {
migrateLegacyConfigForCLI()
}
if cfg, err := config.Load(); err == nil {
if cfg.Language != "" {
i18n.DetectLanguage(cfg.Language)
@@ -50,7 +57,7 @@ func Run(args []string, version string) int {
return welcome(version)
}
cmd, rest := args[0], args[1:]
rest := args[1:]
switch cmd {
case "run":
return runAgent(rest)
@@ -92,6 +99,21 @@ func Run(args []string, version string) int {
}
}
func shouldMigrateLegacyConfigForCLI(cmd string) bool {
switch cmd {
case "", "run", "chat", "code", "serve", "setup", "init", "acp", "mcp", "codegraph", "doctor":
return true
default:
return false
}
}
func migrateLegacyConfigForCLI() {
if _, err := config.MigrateLegacyIfNeeded(); err != nil {
fmt.Fprintln(os.Stderr, "warning: config migration failed:", err)
}
}
func configureCLIThemeFromConfig() {
if cfg, err := config.Load(); err == nil {
configureCLIThemeWithStyle(cfg.UITheme(), cfg.UIThemeStyle())
+70
View File
@@ -53,6 +53,17 @@ func mustGetwd(t *testing.T) string {
return cwd
}
func isolateCLIConfigHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
t.Chdir(t.TempDir())
return home
}
func TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {
defer func(prev func() (terminalRGB, bool)) {
queryTerminalBackgroundForTheme = prev
@@ -81,6 +92,65 @@ func TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {
}
}
func TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {
isolateCLIConfigHome(t)
legacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), "reasonix.toml")
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyPath, []byte(`
default_model = "deepseek-flash"
[[plugins]]
name = "legacy-cli"
command = "legacy-bin"
`), 0o644); err != nil {
t.Fatal(err)
}
out := captureStdout(t, func() {
if rc := Run([]string{"mcp", "list"}, "test-version"); rc != 0 {
t.Fatalf("mcp list rc = %d, want 0", rc)
}
})
if !strings.Contains(out, "legacy-cli") {
t.Fatalf("mcp list should include migrated legacy config:\n%s", out)
}
body, err := os.ReadFile(config.UserConfigPath())
if err != nil {
t.Fatalf("read migrated user config: %v", err)
}
for _, want := range []string{`config_version = 2`, `[desktop]`, `name = "legacy-cli"`} {
if !strings.Contains(string(body), want) {
t.Fatalf("migrated config missing %q:\n%s", want, body)
}
}
}
func TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {
isolateCLIConfigHome(t)
legacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), "reasonix.toml")
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyPath, []byte(`default_model = "deepseek-flash"`), 0o644); err != nil {
t.Fatal(err)
}
out := captureStdout(t, func() {
if rc := Run([]string{"version"}, "test-version"); rc != 0 {
t.Fatalf("version rc = %d, want 0", rc)
}
})
if !strings.Contains(out, "reasonix test-version") {
t.Fatalf("version output = %q", out)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("version should not migrate legacy config, stat err=%v", err)
}
}
// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use
// the same env var) is asked only once, and entered keys become env lines.
func TestConfigureKeys(t *testing.T) {
+100 -23
View File
@@ -39,27 +39,40 @@ func SkillNameKey(name string) string {
// Config is Reasonix's runtime configuration.
type Config struct {
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
UI UIConfig `toml:"ui"`
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Network NetworkConfig `toml:"network"`
Plugins []PluginEntry `toml:"plugins"`
Skills SkillsConfig `toml:"skills"`
Codegraph CodegraphConfig `toml:"codegraph"`
Statusline StatuslineConfig `toml:"statusline"`
LSP LSPConfig `toml:"lsp"`
ConfigVersion int `toml:"config_version"`
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
UI UIConfig `toml:"ui"`
Desktop DesktopConfig `toml:"desktop"`
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Network NetworkConfig `toml:"network"`
Plugins []PluginEntry `toml:"plugins"`
Skills SkillsConfig `toml:"skills"`
Codegraph CodegraphConfig `toml:"codegraph"`
Statusline StatuslineConfig `toml:"statusline"`
LSP LSPConfig `toml:"lsp"`
}
// UIConfig controls presentation-only settings. Theme affects CLI rendering; the
// desktop frontend keeps its own browser-local theme setting.
// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in
// DesktopConfig so desktop preferences cannot alter terminal output or prompts.
type UIConfig struct {
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier
CloseBehavior string `toml:"close_behavior"` // legacy desktop close behavior; prefer desktop.close_behavior
}
// DesktopConfig controls desktop-only UI preferences. It is intentionally
// separate from top-level language and [ui] so desktop choices do not affect CLI
// language, terminal colours, or provider-visible prompt/request data.
type DesktopConfig struct {
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
Theme string `toml:"theme"` // auto|dark|light; empty resolves to dark
ThemeStyle string `toml:"theme_style"` // graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier
CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior
}
// UITheme normalizes ui.theme to a supported value.
@@ -77,14 +90,77 @@ func (c *Config) UITheme() string {
// UIThemeStyle normalizes ui.theme_style. Empty means "pick the default style
// for the resolved light/dark shell".
func (c *Config) UIThemeStyle() string {
switch strings.ToLower(strings.TrimSpace(c.UI.ThemeStyle)) {
return normalizeThemeStyle(c.UI.ThemeStyle)
}
func normalizeThemeStyle(style string) string {
switch strings.ToLower(strings.TrimSpace(style)) {
case "graphite", "ember", "aurora", "midnight", "sandstone", "porcelain", "linen", "glacier":
return strings.ToLower(strings.TrimSpace(c.UI.ThemeStyle))
return strings.ToLower(strings.TrimSpace(style))
default:
return ""
}
}
func normalizeCloseBehavior(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "quit", "exit":
return "quit"
default:
return "background"
}
}
// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect
// from the browser/OS locale; it deliberately does not read top-level language,
// which is used by the CLI/model-facing runtime.
func (c *Config) DesktopLanguage() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {
case "en":
return "en"
case "zh":
return "zh"
default:
return ""
}
}
// DesktopTheme normalizes desktop.theme. New desktop users default to the dark
// graphite product look; an explicit auto/light/dark is preserved.
func (c *Config) DesktopTheme() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {
case "auto":
return "auto"
case "light":
return "light"
case "dark":
return "dark"
default:
return "dark"
}
}
// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend
// chooses the default style for the resolved desktop theme.
func (c *Config) DesktopThemeStyle() string {
return normalizeThemeStyle(c.Desktop.ThemeStyle)
}
// DesktopCloseBehavior normalizes the desktop close-window preference. It falls
// back to the legacy ui.close_behavior value for configs written before [desktop]
// existed.
func (c *Config) DesktopCloseBehavior() string {
if strings.TrimSpace(c.Desktop.CloseBehavior) != "" {
return normalizeCloseBehavior(c.Desktop.CloseBehavior)
}
return normalizeCloseBehavior(c.UI.CloseBehavior)
}
// UICloseBehavior is the legacy name for DesktopCloseBehavior.
func (c *Config) UICloseBehavior() string {
return c.DesktopCloseBehavior()
}
// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,
// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the
// servers themselves are never bundled — each resolves on PATH and the tool
@@ -537,8 +613,9 @@ const LanguagePolicy = `Reply in the same language the user is using in their mo
// Default returns the built-in default configuration (DeepSeek + MiMo presets).
func Default() *Config {
return &Config{
DefaultModel: "deepseek-flash",
UI: UIConfig{Theme: "auto"},
ConfigVersion: 2,
DefaultModel: "deepseek-flash",
UI: UIConfig{Theme: "auto"},
Agent: AgentConfig{
SystemPrompt: DefaultSystemPrompt,
// 0 = no step cap: the agent loops until the model gives a final answer,
@@ -902,7 +979,7 @@ func SourcePathForRoot(root string) string {
// WriteFile writes the configuration to path as annotated TOML.
func (c *Config) WriteFile(path string) error {
return os.WriteFile(path, []byte(RenderTOML(c)), 0o644)
return os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)
}
// Provider returns the named provider entry.
+88 -2
View File
@@ -83,7 +83,7 @@ func (c *Config) SetProviderEffort(name, effort string) error {
return fmt.Errorf("set provider effort: no provider %q", name)
}
// SetLanguage pins the CLI UI language; empty/auto clears the override so runtime detection falls back to REASONIX_LANG / locale.
// SetLanguage pins the CLI UI/model language; empty/auto clears the override so runtime detection falls back to REASONIX_LANG / locale.
func (c *Config) SetLanguage(lang string) error {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "", "auto":
@@ -98,6 +98,67 @@ func (c *Config) SetLanguage(lang string) error {
return nil
}
// SetDesktopLanguage pins the desktop UI language. It intentionally does not
// modify Config.Language, which is used by the CLI/model-facing runtime.
func (c *Config) SetDesktopLanguage(lang string) error {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "", "auto":
c.Desktop.Language = ""
case "en":
c.Desktop.Language = "en"
case "zh":
c.Desktop.Language = "zh"
default:
return fmt.Errorf("desktop language %q: must be auto|en|zh", lang)
}
return nil
}
// SetDesktopAppearance sets desktop-only theme preferences. It must not affect
// CLI theme settings or provider-visible request data.
func (c *Config) SetDesktopAppearance(theme, style string) error {
switch strings.ToLower(strings.TrimSpace(theme)) {
case "auto":
c.Desktop.Theme = "auto"
case "light":
c.Desktop.Theme = "light"
case "", "dark":
c.Desktop.Theme = "dark"
default:
return fmt.Errorf("desktop theme %q: must be auto|dark|light", theme)
}
if strings.TrimSpace(style) == "" {
c.Desktop.ThemeStyle = ""
return nil
}
normalized := normalizeThemeStyle(style)
if normalized == "" {
return fmt.Errorf("desktop theme style %q: must be graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier", style)
}
c.Desktop.ThemeStyle = normalized
return nil
}
// SetDesktopCloseBehavior sets the desktop close-window preference. It is
// intentionally UI-only and must not affect model prompts or provider-visible
// request data.
func (c *Config) SetDesktopCloseBehavior(mode string) error {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "quit", "exit":
c.Desktop.CloseBehavior = "quit"
case "", "background", "hide":
c.Desktop.CloseBehavior = "background"
default:
return fmt.Errorf("close behavior %q: must be quit|background", mode)
}
return nil
}
// SetUICloseBehavior is kept for callers compiled against the old edit API.
func (c *Config) SetUICloseBehavior(mode string) error {
return c.SetDesktopCloseBehavior(mode)
}
// SetProviderThinking updates a provider's provider-specific thinking mode knob.
func (c *Config) SetProviderThinking(name, thinking string) error {
for i := range c.Providers {
@@ -429,6 +490,10 @@ func validatePlugin(e PluginEntry) error {
// half-written reasonix.toml that fails to parse on next load. Parent directories
// are created as needed.
func (c *Config) SaveTo(path string) error {
return c.SaveToScope(path, renderScopeForPath(path))
}
func (c *Config) SaveToScope(path string, scope RenderScope) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("save: empty config path")
}
@@ -441,7 +506,7 @@ func (c *Config) SaveTo(path string) error {
return fmt.Errorf("save: create temp: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.WriteString(RenderTOML(c)); err != nil {
if _, err := tmp.WriteString(RenderTOMLForScope(c, scope)); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("save: write: %w", err)
@@ -453,6 +518,27 @@ func (c *Config) SaveTo(path string) error {
return fileutil.ReplaceFile(tmpPath, path)
}
func renderScopeForPath(path string) RenderScope {
if isUserConfigPath(path) {
return RenderScopeUser
}
return RenderScopeProject
}
func isUserConfigPath(path string) bool {
path = strings.TrimSpace(path)
uc := strings.TrimSpace(userConfigPath())
if path == "" || uc == "" {
return false
}
pathAbs, pathErr := filepath.Abs(path)
ucAbs, ucErr := filepath.Abs(uc)
if pathErr == nil && ucErr == nil {
return filepath.Clean(pathAbs) == filepath.Clean(ucAbs)
}
return filepath.Clean(path) == filepath.Clean(uc)
}
// Save writes the configuration back to the file it was loaded from
// (SourcePath), or to ./reasonix.toml when none exists yet — the conventional
// project-local target a fresh GUI session would create.
+117
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/BurntSushi/toml"
@@ -58,6 +59,90 @@ func TestUIThemeStyleNormalizes(t *testing.T) {
}
}
func TestUICloseBehaviorNormalizes(t *testing.T) {
c := Default()
for _, tt := range []struct {
in string
want string
}{
{"", "background"},
{"QUIT", "quit"},
{"exit", "quit"},
{" background ", "background"},
{"hide", "background"},
{"unknown", "background"},
} {
c.UI.CloseBehavior = tt.in
if got := c.UICloseBehavior(); got != tt.want {
t.Errorf("UICloseBehavior(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) {
c := Default()
c.Language = "zh"
c.UI.Theme = "light"
c.UI.ThemeStyle = "glacier"
if err := c.SetDesktopLanguage("en"); err != nil {
t.Fatalf("SetDesktopLanguage: %v", err)
}
if err := c.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("SetDesktopAppearance: %v", err)
}
if c.Language != "zh" {
t.Fatalf("CLI language changed to %q", c.Language)
}
if got := c.UITheme(); got != "light" {
t.Fatalf("CLI theme = %q, want light", got)
}
if got := c.UIThemeStyle(); got != "glacier" {
t.Fatalf("CLI theme style = %q, want glacier", got)
}
if got := c.DesktopLanguage(); got != "en" {
t.Fatalf("desktop language = %q, want en", got)
}
if got := c.DesktopTheme(); got != "dark" {
t.Fatalf("desktop theme = %q, want dark", got)
}
if got := c.DesktopThemeStyle(); got != "graphite" {
t.Fatalf("desktop theme style = %q, want graphite", got)
}
}
func TestDesktopCloseBehaviorFallsBackToLegacyUI(t *testing.T) {
c := Default()
c.UI.CloseBehavior = "quit"
if got := c.DesktopCloseBehavior(); got != "quit" {
t.Fatalf("legacy close behavior = %q, want quit", got)
}
c.Desktop.CloseBehavior = "background"
if got := c.DesktopCloseBehavior(); got != "background" {
t.Fatalf("desktop close behavior = %q, want background", got)
}
}
func TestSetUICloseBehavior(t *testing.T) {
c := Default()
if err := c.SetUICloseBehavior("background"); err != nil {
t.Fatalf("SetUICloseBehavior background: %v", err)
}
if got := c.UICloseBehavior(); got != "background" {
t.Fatalf("close behavior = %q, want background", got)
}
if err := c.SetUICloseBehavior("quit"); err != nil {
t.Fatalf("SetUICloseBehavior quit: %v", err)
}
if got := c.UICloseBehavior(); got != "quit" {
t.Fatalf("close behavior = %q, want quit", got)
}
if err := c.SetUICloseBehavior("later"); err == nil {
t.Fatal("expected error for invalid close behavior")
}
}
func TestSetPlannerModel(t *testing.T) {
c := Default()
if err := c.SetPlannerModel("deepseek-pro"); err != nil {
@@ -550,6 +635,38 @@ func TestSaveToRoundTrips(t *testing.T) {
}
}
func TestSaveToScopesUserAndProjectFiles(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
c := Default()
c.Desktop.Theme = "dark"
c.Desktop.ThemeStyle = "graphite"
c.Desktop.CloseBehavior = "background"
userPath := UserConfigPath()
if err := c.SaveTo(userPath); err != nil {
t.Fatalf("SaveTo user config: %v", err)
}
userBody, err := os.ReadFile(userPath)
if err != nil {
t.Fatalf("read user config: %v", err)
}
if !strings.Contains(string(userBody), "[desktop]") {
t.Fatalf("user config should include desktop preferences:\n%s", userBody)
}
projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
if err := c.SaveTo(projectPath); err != nil {
t.Fatalf("SaveTo project config: %v", err)
}
projectBody, err := os.ReadFile(projectPath)
if err != nil {
t.Fatalf("read project config: %v", err)
}
if strings.Contains(string(projectBody), "[desktop]") || strings.Contains(string(projectBody), "close_behavior") {
t.Fatalf("project config should not include desktop preferences:\n%s", projectBody)
}
}
func TestSetNetworkRejectsIncompleteCustomProxy(t *testing.T) {
c := Default()
if err := c.SetNetwork(NetworkConfig{ProxyMode: "custom"}); err == nil {
+58 -5
View File
@@ -59,10 +59,11 @@ func (r *MigrationResult) Notice() string {
return b.String()
}
// MigrateLegacyIfNeeded performs a one-time, non-destructive import of a v0.x
// install (~/.reasonix/config.json) into the v1+ user config when the latter does
// not exist yet. It never modifies or deletes the legacy files. Returns nil when
// there is nothing to migrate (no legacy install, or a v1+ config already present).
// MigrateLegacyIfNeeded performs a one-time, non-destructive import of older
// installs into the current user config when the latter does not exist yet. It
// checks v1-era TOML first, then v0.5/v0.x ~/.reasonix/config.json, and never
// modifies or deletes the legacy files. Returns nil when there is nothing to
// migrate, or when the current user config already exists.
func MigrateLegacyIfNeeded() (*MigrationResult, error) {
dest := userConfigPath()
if dest == "" {
@@ -75,6 +76,9 @@ func MigrateLegacyIfNeeded() (*MigrationResult, error) {
if err != nil {
return nil, nil
}
if res, err := migrateLegacyTOMLIfNeeded(dest, home); res != nil || err != nil {
return res, err
}
src := filepath.Join(home, ".reasonix", "config.json")
data, err := os.ReadFile(src)
if err != nil {
@@ -90,7 +94,9 @@ func MigrateLegacyIfNeeded() (*MigrationResult, error) {
res := &MigrationResult{From: src, To: dest}
if legacy.Lang != "" {
cfg.Language = legacy.Lang
_ = cfg.SetDesktopLanguage(legacy.Lang)
}
migrateLegacyBaseURL(cfg, legacy.BaseURL)
cfg.Plugins = legacyPlugins(legacy)
res.Plugins = len(cfg.Plugins)
@@ -100,7 +106,7 @@ func MigrateLegacyIfNeeded() (*MigrationResult, error) {
res.KeyToEnv = true
if base := strings.TrimSpace(legacy.BaseURL); base != "" && !strings.Contains(base, "deepseek.com") {
res.Warnings = append(res.Warnings, "your previous base_url was "+base+
" — add a [[providers]] entry pointing at it; the key was saved as DEEPSEEK_API_KEY")
" — it was applied to the built-in DeepSeek providers; verify models if this endpoint is not DeepSeek-compatible")
}
}
@@ -118,6 +124,53 @@ func MigrateLegacyIfNeeded() (*MigrationResult, error) {
return res, nil
}
func migrateLegacyTOMLIfNeeded(dest, home string) (*MigrationResult, error) {
for _, src := range legacyTOMLPaths(dest, home) {
if src == "" || filepath.Clean(src) == filepath.Clean(dest) {
continue
}
if _, err := os.Stat(src); err != nil {
continue
}
cfg := Default()
if err := mergeFile(cfg, src); err != nil {
return nil, fmt.Errorf("parse legacy config %s: %w", src, err)
}
cfg.ConfigVersion = Default().ConfigVersion
if strings.TrimSpace(cfg.Desktop.CloseBehavior) == "" && strings.TrimSpace(cfg.UI.CloseBehavior) != "" {
cfg.Desktop.CloseBehavior = cfg.DesktopCloseBehavior()
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
if err := cfg.WriteFile(dest); err != nil {
return nil, fmt.Errorf("write %s: %w", dest, err)
}
return &MigrationResult{From: src, To: dest, Plugins: len(cfg.Plugins)}, nil
}
return nil, nil
}
func legacyTOMLPaths(dest, home string) []string {
paths := []string{filepath.Join(filepath.Dir(dest), "reasonix.toml")}
if home != "" {
paths = append(paths, filepath.Join(home, ".reasonix", "reasonix.toml"))
}
return paths
}
func migrateLegacyBaseURL(cfg *Config, baseURL string) {
baseURL = strings.TrimSpace(baseURL)
if cfg == nil || baseURL == "" {
return
}
for i := range cfg.Providers {
if cfg.Providers[i].APIKeyEnv == "DEEPSEEK_API_KEY" {
cfg.Providers[i].BaseURL = baseURL
}
}
}
func legacyPlugins(legacy legacyConfig) []PluginEntry {
if len(legacy.MCPServers) == 0 {
return nil
+95 -1
View File
@@ -65,7 +65,7 @@ func TestMigrateImportsKeyPluginsAndLang(t *testing.T) {
t.Fatalf("read dest config: %v", err)
}
toml := string(got)
for _, want := range []string{`language = "zh"`, `name = "fs"`, `name = "stripe"`, `type = "http"`, `auto_start = false`} {
for _, want := range []string{`language = "zh"`, `[desktop]`, `language = "zh"`, `name = "fs"`, `name = "stripe"`, `type = "http"`, `auto_start = false`} {
if !strings.Contains(toml, want) {
t.Errorf("dest config missing %q:\n%s", want, toml)
}
@@ -114,6 +114,90 @@ func TestMigrateSkipsWhenDestExists(t *testing.T) {
}
}
func TestMigrateImportsLegacyV1TOMLBeforeJSON(t *testing.T) {
srcJSON, dest, home := legacyHome(t)
legacyTOML := filepath.Join(filepath.Dir(dest), "reasonix.toml")
if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyTOML, []byte(`
default_model = "deepseek-flash"
language = "en"
[ui]
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
[[plugins]]
name = "legacy-v1"
command = "legacy-bin"
`), 0o644); err != nil {
t.Fatal(err)
}
writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win"}`)
res, err := MigrateLegacyIfNeeded()
if err != nil {
t.Fatalf("migrate: %v", err)
}
if res == nil || res.From != legacyTOML {
t.Fatalf("expected v1 TOML migration, got %+v", res)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("read migrated config: %v", err)
}
text := string(got)
for _, want := range []string{`config_version = 2`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} {
if !strings.Contains(text, want) {
t.Fatalf("migrated TOML missing %q:\n%s", want, text)
}
}
if _, err := os.Stat(filepath.Join(home, ".env")); !os.IsNotExist(err) {
t.Fatalf("v1 TOML migration should not import lower-priority JSON key, env stat err=%v", err)
}
}
func TestMigrateImportsLegacyV1HomeTOMLBeforeJSON(t *testing.T) {
srcJSON, dest, home := legacyHome(t)
legacyTOML := filepath.Join(home, ".reasonix", "reasonix.toml")
if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyTOML, []byte(`
default_model = "deepseek-flash"
[[plugins]]
name = "legacy-home-v1"
command = "legacy-home-bin"
`), 0o644); err != nil {
t.Fatal(err)
}
writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win","mcpServers":{"json":{"command":"json-bin"}}}`)
res, err := MigrateLegacyIfNeeded()
if err != nil {
t.Fatalf("migrate: %v", err)
}
if res == nil || res.From != legacyTOML {
t.Fatalf("expected home v1 TOML migration, got %+v", res)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("read migrated config: %v", err)
}
text := string(got)
if !strings.Contains(text, `name = "legacy-home-v1"`) {
t.Fatalf("home v1 plugin was not migrated:\n%s", text)
}
if strings.Contains(text, `name = "json"`) {
t.Fatalf("lower-priority v0.5 JSON should not be merged when v1 TOML exists:\n%s", text)
}
}
func TestMigrateNoLegacyIsNoop(t *testing.T) {
legacyHome(t)
res, err := MigrateLegacyIfNeeded()
@@ -148,4 +232,14 @@ func TestMigrateCustomBaseURLWarns(t *testing.T) {
if len(res.Warnings) == 0 {
t.Error("a non-DeepSeek base_url should produce a warning")
}
cfg, err := Load()
if err != nil {
t.Fatalf("load migrated config: %v", err)
}
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
p, ok := cfg.Provider(name)
if !ok || p.BaseURL != "https://my-proxy.example/v1" {
t.Fatalf("%s base_url was not migrated: %+v", name, p)
}
}
}
+174 -82
View File
@@ -2,21 +2,48 @@ package config
import (
"fmt"
"reflect"
"sort"
"strconv"
"strings"
)
type RenderScope string
const (
RenderScopeFull RenderScope = "full"
RenderScopeUser RenderScope = "user"
RenderScopeProject RenderScope = "project"
)
// RenderTOML renders the config as annotated TOML in the `reasonix setup` house style:
// comments preserved, system_prompt as a multi-line string, helpful hints. The
// output round-trips back through Load (see render_test.go).
func RenderTOML(c *Config) string {
return RenderTOMLForScope(c, RenderScopeFull)
}
// RenderTOMLForScope renders an annotated TOML file for a specific persistence
// target. User configs can carry desktop and account-level preferences; project
// reasonix.toml stays focused on project behavior and intentionally excludes
// desktop-only preferences.
func RenderTOMLForScope(c *Config, scope RenderScope) string {
if c == nil {
c = Default()
}
switch scope {
case RenderScopeUser, RenderScopeProject:
default:
scope = RenderScopeFull
}
defaults := Default()
var b strings.Builder
b.WriteString("# Reasonix configuration.\n")
b.WriteString("# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n")
b.WriteString("# Secrets come from the environment via api_key_env; never put keys here.\n\n")
fmt.Fprintf(&b, "config_version = %d # schema marker for diagnostics; old versions may ignore it\n", configVersion(c))
fmt.Fprintf(&b, "default_model = %q\n", c.DefaultModel)
if c.Language != "" {
fmt.Fprintf(&b, "language = %q # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG\n", c.Language)
@@ -25,59 +52,87 @@ func RenderTOML(c *Config) string {
}
b.WriteString("\n")
b.WriteString("[ui]\n")
fmt.Fprintf(&b, "theme = %q # auto|dark|light; CLI colors only; REASONIX_THEME can override per run\n", c.UITheme())
if style := c.UIThemeStyle(); style != "" {
fmt.Fprintf(&b, "theme_style = %q # accent palette; REASONIX_THEME_STYLE can override per run\n", style)
} else {
b.WriteString("# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n")
if shouldRenderUI(c, defaults, scope) {
b.WriteString("[ui]\n")
fmt.Fprintf(&b, "theme = %q # auto|dark|light; CLI colors only; REASONIX_THEME can override per run\n", c.UITheme())
if style := c.UIThemeStyle(); style != "" {
fmt.Fprintf(&b, "theme_style = %q # CLI accent palette; REASONIX_THEME_STYLE can override per run\n", style)
} else {
b.WriteString("# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n")
}
if strings.TrimSpace(c.UI.CloseBehavior) != "" && scope == RenderScopeProject {
fmt.Fprintf(&b, "close_behavior = %q # legacy desktop close behavior; prefer [desktop].close_behavior in user config\n", c.DesktopCloseBehavior())
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString("[network]\n")
fmt.Fprintf(&b, "proxy_mode = %q # auto|env|custom|off; auto currently uses env proxy\n", c.NetworkProxyMode())
if c.Network.ProxyURL != "" {
fmt.Fprintf(&b, "proxy_url = %q # custom override, e.g. socks5://127.0.0.1:7890\n", c.Network.ProxyURL)
} else {
b.WriteString("# proxy_url = \"socks5://127.0.0.1:7890\" # optional custom override\n")
if scope != RenderScopeProject {
b.WriteString("[desktop]\n")
if lang := c.DesktopLanguage(); lang != "" {
fmt.Fprintf(&b, "language = %q # desktop UI language; empty/auto = browser/OS auto-detect\n", lang)
} else {
b.WriteString("# language = \"zh\" # desktop UI language; empty/auto = browser/OS auto-detect\n")
}
fmt.Fprintf(&b, "theme = %q # desktop only: auto|dark|light\n", c.DesktopTheme())
if style := c.DesktopThemeStyle(); style != "" {
fmt.Fprintf(&b, "theme_style = %q # desktop accent palette\n", style)
} else {
b.WriteString("# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n")
}
fmt.Fprintf(&b, "close_behavior = %q # desktop: quit|background when the window close button is clicked\n", c.DesktopCloseBehavior())
b.WriteString("\n")
}
if c.Network.NoProxy != "" {
fmt.Fprintf(&b, "no_proxy = %q # honored for proxy_mode = \"custom\"\n", c.Network.NoProxy)
} else {
b.WriteString("# no_proxy = \"localhost,127.0.0.1,.local\" # honored for proxy_mode = \"custom\"\n")
if shouldRenderNetwork(c, defaults, scope) {
b.WriteString("[network]\n")
fmt.Fprintf(&b, "proxy_mode = %q # auto|env|custom|off; auto currently uses env proxy\n", c.NetworkProxyMode())
if c.Network.ProxyURL != "" {
fmt.Fprintf(&b, "proxy_url = %q # custom override, e.g. socks5://127.0.0.1:7890\n", c.Network.ProxyURL)
} else {
b.WriteString("# proxy_url = \"socks5://127.0.0.1:7890\" # optional custom override\n")
}
if c.Network.NoProxy != "" {
fmt.Fprintf(&b, "no_proxy = %q # honored for proxy_mode = \"custom\"\n", c.Network.NoProxy)
} else {
b.WriteString("# no_proxy = \"localhost,127.0.0.1,.local\" # honored for proxy_mode = \"custom\"\n")
}
b.WriteString("\n[network.proxy]\n")
proxyType := c.Network.Proxy.Type
if proxyType == "" {
proxyType = "socks5"
}
fmt.Fprintf(&b, "type = %q # http|https|socks5|socks5h\n", proxyType)
if c.Network.Proxy.Server != "" {
fmt.Fprintf(&b, "server = %q\n", c.Network.Proxy.Server)
} else {
b.WriteString("# server = \"127.0.0.1\"\n")
}
if c.Network.Proxy.Port > 0 {
fmt.Fprintf(&b, "port = %d\n", c.Network.Proxy.Port)
} else {
b.WriteString("# port = 7890\n")
}
if c.Network.Proxy.Username != "" {
fmt.Fprintf(&b, "username = %q\n", c.Network.Proxy.Username)
} else {
b.WriteString("# username = \"\"\n")
}
if c.Network.Proxy.Password != "" {
fmt.Fprintf(&b, "password = %q # supports ${VAR} expansion\n", c.Network.Proxy.Password)
} else {
b.WriteString("# password = \"${REASONIX_PROXY_PASSWORD}\" # optional; supports ${VAR} expansion\n")
}
b.WriteString("\n")
}
b.WriteString("\n[network.proxy]\n")
proxyType := c.Network.Proxy.Type
if proxyType == "" {
proxyType = "socks5"
}
fmt.Fprintf(&b, "type = %q # http|https|socks5|socks5h\n", proxyType)
if c.Network.Proxy.Server != "" {
fmt.Fprintf(&b, "server = %q\n", c.Network.Proxy.Server)
} else {
b.WriteString("# server = \"127.0.0.1\"\n")
}
if c.Network.Proxy.Port > 0 {
fmt.Fprintf(&b, "port = %d\n", c.Network.Proxy.Port)
} else {
b.WriteString("# port = 7890\n")
}
if c.Network.Proxy.Username != "" {
fmt.Fprintf(&b, "username = %q\n", c.Network.Proxy.Username)
} else {
b.WriteString("# username = \"\"\n")
}
if c.Network.Proxy.Password != "" {
fmt.Fprintf(&b, "password = %q # supports ${VAR} expansion\n", c.Network.Proxy.Password)
} else {
b.WriteString("# password = \"${REASONIX_PROXY_PASSWORD}\" # optional; supports ${VAR} expansion\n")
}
b.WriteString("\n")
b.WriteString("[agent]\n")
b.WriteString("system_prompt = \"\"\"\n")
b.WriteString(c.Agent.SystemPrompt)
b.WriteString("\"\"\"\n")
if shouldRenderSystemPrompt(c, defaults, scope) {
b.WriteString("system_prompt = \"\"\"\n")
b.WriteString(c.Agent.SystemPrompt)
b.WriteString("\"\"\"\n")
} else {
b.WriteString("# system_prompt = \"\"\"...\"\"\" # omit to use the built-in prompt for this version\n")
}
if c.Agent.SystemPromptFile != "" {
fmt.Fprintf(&b, "system_prompt_file = %q\n", c.Agent.SystemPromptFile)
} else {
@@ -120,43 +175,45 @@ func RenderTOML(c *Config) string {
}
b.WriteString("\n")
for _, p := range c.Providers {
b.WriteString("[[providers]]\n")
fmt.Fprintf(&b, "name = %q\n", p.Name)
fmt.Fprintf(&b, "kind = %q\n", p.Kind)
fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL)
if len(p.Models) > 0 {
fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models))
if p.Default != "" {
fmt.Fprintf(&b, "default = %q\n", p.Default)
if shouldRenderProviders(c, defaults, scope) {
for _, p := range c.Providers {
b.WriteString("[[providers]]\n")
fmt.Fprintf(&b, "name = %q\n", p.Name)
fmt.Fprintf(&b, "kind = %q\n", p.Kind)
fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL)
if len(p.Models) > 0 {
fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models))
if p.Default != "" {
fmt.Fprintf(&b, "default = %q\n", p.Default)
}
} else if p.Model != "" {
fmt.Fprintf(&b, "model = %q\n", p.Model)
}
} else if p.Model != "" {
fmt.Fprintf(&b, "model = %q\n", p.Model)
if p.ModelsURL != "" {
fmt.Fprintf(&b, "models_url = %q # auto-fetch models from this URL on startup\n", p.ModelsURL)
}
fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
if p.BalanceURL != "" {
fmt.Fprintf(&b, "balance_url = %q # optional; wallet-balance endpoint shown in the status bar\n", p.BalanceURL)
}
if p.ContextWindow > 0 {
fmt.Fprintf(&b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow)
}
if p.Price != nil {
fmt.Fprintf(&b, "price = { cache_hit = %v, input = %v, output = %v, currency = %q } # per 1M tokens\n",
p.Price.CacheHit, p.Price.Input, p.Price.Output, p.Price.Symbol())
}
if p.Thinking != "" {
fmt.Fprintf(&b, "thinking = %q\n", p.Thinking)
}
if p.Effort != "" {
fmt.Fprintf(&b, "effort = %q\n", p.Effort)
}
if p.NoProxy {
b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n")
}
b.WriteString("\n")
}
if p.ModelsURL != "" {
fmt.Fprintf(&b, "models_url = %q # auto-fetch models from this URL on startup\n", p.ModelsURL)
}
fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
if p.BalanceURL != "" {
fmt.Fprintf(&b, "balance_url = %q # optional; wallet-balance endpoint shown in the status bar\n", p.BalanceURL)
}
if p.ContextWindow > 0 {
fmt.Fprintf(&b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow)
}
if p.Price != nil {
fmt.Fprintf(&b, "price = { cache_hit = %v, input = %v, output = %v, currency = %q } # per 1M tokens\n",
p.Price.CacheHit, p.Price.Input, p.Price.Output, p.Price.Symbol())
}
if p.Thinking != "" {
fmt.Fprintf(&b, "thinking = %q\n", p.Thinking)
}
if p.Effort != "" {
fmt.Fprintf(&b, "effort = %q\n", p.Effort)
}
if p.NoProxy {
b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n")
}
b.WriteString("\n")
}
b.WriteString("[tools]\n")
@@ -288,6 +345,41 @@ func RenderTOML(c *Config) string {
return b.String()
}
func configVersion(c *Config) int {
if c != nil && c.ConfigVersion > 0 {
return c.ConfigVersion
}
return Default().ConfigVersion
}
func shouldRenderUI(c, defaults *Config, scope RenderScope) bool {
if scope != RenderScopeProject {
return true
}
return !reflect.DeepEqual(c.UI, defaults.UI)
}
func shouldRenderNetwork(c, defaults *Config, scope RenderScope) bool {
if scope != RenderScopeProject {
return true
}
return !reflect.DeepEqual(c.Network, defaults.Network)
}
func shouldRenderProviders(c, defaults *Config, scope RenderScope) bool {
if scope != RenderScopeProject {
return true
}
return !reflect.DeepEqual(c.Providers, defaults.Providers)
}
func shouldRenderSystemPrompt(c, defaults *Config, scope RenderScope) bool {
if scope == RenderScopeFull {
return true
}
return strings.TrimSpace(c.Agent.SystemPrompt) != "" && c.Agent.SystemPrompt != defaults.Agent.SystemPrompt
}
// renderStringArray renders a []string as a TOML inline array.
func renderStringArray(ss []string) string {
var b strings.Builder
+65
View File
@@ -1,6 +1,7 @@
package config
import (
"strings"
"testing"
"github.com/BurntSushi/toml"
@@ -14,6 +15,10 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
orig.Language = "zh"
orig.UI.Theme = "light"
orig.UI.ThemeStyle = "glacier"
orig.Desktop.Language = "en"
orig.Desktop.Theme = "dark"
orig.Desktop.ThemeStyle = "graphite"
orig.Desktop.CloseBehavior = "background"
orig.Agent.AutoPlanClassifier = "deepseek-flash"
orig.Agent.SubagentModel = "mimo-pro"
orig.Agent.SubagentModels = map[string]string{"review": "deepseek-pro"}
@@ -55,6 +60,9 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
if got.DefaultModel != "mimo-pro" {
t.Errorf("default_model = %q, want mimo-pro", got.DefaultModel)
}
if got.ConfigVersion != 2 {
t.Errorf("config_version = %d, want 2", got.ConfigVersion)
}
if got.Language != "zh" {
t.Errorf("language = %q, want zh", got.Language)
}
@@ -64,6 +72,18 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
if got.UI.ThemeStyle != "glacier" {
t.Errorf("ui.theme_style = %q, want glacier", got.UI.ThemeStyle)
}
if got.Desktop.Language != "en" {
t.Errorf("desktop.language = %q, want en", got.Desktop.Language)
}
if got.Desktop.Theme != "dark" {
t.Errorf("desktop.theme = %q, want dark", got.Desktop.Theme)
}
if got.Desktop.ThemeStyle != "graphite" {
t.Errorf("desktop.theme_style = %q, want graphite", got.Desktop.ThemeStyle)
}
if got.Desktop.CloseBehavior != "background" {
t.Errorf("desktop.close_behavior = %q, want background", got.Desktop.CloseBehavior)
}
if got.Agent.MaxSteps != orig.Agent.MaxSteps {
t.Errorf("max_steps = %d, want %d", got.Agent.MaxSteps, orig.Agent.MaxSteps)
}
@@ -151,4 +171,49 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
}
}
func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
c := Default()
c.Language = "zh"
c.Desktop.Language = "zh"
c.Desktop.Theme = "dark"
c.Desktop.ThemeStyle = "graphite"
c.Desktop.CloseBehavior = "background"
user := RenderTOMLForScope(c, RenderScopeUser)
for _, want := range []string{"config_version = 2", "[desktop]", `theme = "dark"`, `close_behavior = "background"`} {
if !strings.Contains(user, want) {
t.Fatalf("user render missing %q:\n%s", want, user)
}
}
project := RenderTOMLForScope(c, RenderScopeProject)
for _, forbidden := range []string{"[desktop]", "close_behavior ="} {
if strings.Contains(project, forbidden) {
t.Fatalf("project render should not contain %q:\n%s", forbidden, project)
}
}
if strings.Contains(project, "\nsystem_prompt = \"\"\"") {
t.Fatalf("project render should not pin the built-in system prompt:\n%s", project)
}
if !strings.Contains(project, "# system_prompt =") {
t.Fatalf("project render should leave a system prompt hint:\n%s", project)
}
}
func TestProjectRenderPreservesNonDefaultLegacySections(t *testing.T) {
c := Default()
c.UI.Theme = "light"
c.UI.CloseBehavior = "quit"
c.Network.ProxyMode = "custom"
c.Network.Proxy.Server = "127.0.0.1"
c.Network.Proxy.Port = 7890
project := RenderTOMLForScope(c, RenderScopeProject)
for _, want := range []string{"[ui]", `theme = "light"`, `close_behavior = "quit"`, "[network]", `proxy_mode = "custom"`, `server = "127.0.0.1"`} {
if !strings.Contains(project, want) {
t.Fatalf("project render missing legacy/non-default %q:\n%s", want, project)
}
}
}
func boolPtr(v bool) *bool { return &v }
+39 -5
View File
@@ -820,6 +820,17 @@ func (c *Controller) Fork(turn int) (string, error) {
}
func (c *Controller) ForkNamed(turn int, name string) (string, error) {
return c.forkNamed(turn, name, true)
}
// ForkSession copies the conversation at the start of turn into a new session
// file without switching this controller to it. Desktop uses this to open the
// branch in a new tab while the source tab keeps its current transcript.
func (c *Controller) ForkSession(turn int, name string) (string, error) {
return c.forkNamed(turn, name, false)
}
func (c *Controller) forkNamed(turn int, name string, switchToFork bool) (string, error) {
if c.executor == nil {
return "", c.rewindFail(fmt.Errorf("checkpoints unavailable"))
}
@@ -864,16 +875,25 @@ func (c *Controller) ForkNamed(turn int, name string) (string, error) {
}); err != nil {
return "", c.rewindFail(err)
}
c.executor.SetSession(sess)
c.mu.Lock()
c.sessionPath = newPath
c.mu.Unlock()
c.rebindCheckpoints(newPath)
if switchToFork {
c.executor.SetSession(sess)
c.mu.Lock()
c.sessionPath = newPath
c.mu.Unlock()
c.rebindCheckpoints(newPath)
}
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: fmt.Sprintf("forked conversation at turn %d into a new session", turn)})
return newPath, nil
}
func (c *Controller) CheckpointHasBoundary(turn int) bool {
c.mu.Lock()
defer c.mu.Unlock()
_, ok := c.cpBound[turn]
return ok
}
// Branch copies the current conversation into a child branch and switches to it.
// Unlike Fork, it branches at the current tip and does not require a checkpoint.
func (c *Controller) Branch(name string) (string, error) {
@@ -1297,6 +1317,13 @@ func (c *Controller) AddMCPServer(e config.PluginEntry) (int, error) {
return n, nil
}
// ConnectMCPServer connects an MCP server entry for this session without writing
// it to config. Desktop owns config placement so it can keep user-level settings
// out of project reasonix.toml while preserving the CLI AddMCPServer semantics.
func (c *Controller) ConnectMCPServer(e config.PluginEntry) (int, error) {
return c.connectMCPServer(e)
}
func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
exp := e.ExpandedPlugin()
return c.connectMCPSpec(plugin.Spec{
@@ -1375,6 +1402,13 @@ func (c *Controller) ConnectConfiguredMCPServer(name string) (int, error) {
return 0, fmt.Errorf("no configured MCP server named %q", name)
}
// ConnectCodegraphMCPServer connects the built-in CodeGraph server using an
// already-resolved config. Desktop uses this after saving user-level settings so
// a stale project config cannot override the just-applied choice.
func (c *Controller) ConnectCodegraphMCPServer(cfg *config.Config) (int, error) {
return c.connectCodegraphMCPServer(cfg)
}
func (c *Controller) connectCodegraphMCPServer(cfg *config.Config) (int, error) {
if !cfg.Codegraph.Enabled {
return 0, fmt.Errorf("codegraph is disabled in config")
+2
View File
@@ -72,6 +72,8 @@ type Messages struct {
ChatStatusIdle string // shortcuts hint when idle
ChatStatusYoloIdle string // shortcuts hint when idle in YOLO/bypass mode
ChatStatusCycleHint string // mode-cycle shortcut hint shown when no modal prompt owns the status row
ChatStatusCacheNowFmt string // cache status tag, "%s" = latest-turn hit rate with percent sign
ChatStatusCacheAvgFmt string // cache status tag, "%s" = session-average hit rate with percent sign
ChatStatusPlanApproval string // shortcuts hint while a plan is pending
PlanApprovalPrompt string // one-line "plan above is ready" banner shown above the input
ChatStatusToolApproval string // shortcuts hint while a tool call awaits approval
+2
View File
@@ -46,6 +46,8 @@ var English = Messages{
ChatStatusIdle: "ready",
ChatStatusYoloIdle: "approvals skipped",
ChatStatusCycleHint: "shift+tab to cycle",
ChatStatusCacheNowFmt: "turn hit %s",
ChatStatusCacheAvgFmt: "avg %s",
ChatStatusPlanApproval: "Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn scrolls",
PlanApprovalPrompt: "Plan ready above — Enter/y to approve & execute, n/Esc to keep planning",
ChatStatusToolApproval: "1 approve once · 2 allow this session · 3 always allow (save) · 4 deny · y/a/p/n also work · Ctrl-C cancels turn",
+2
View File
@@ -47,6 +47,8 @@ var Chinese = Messages{
ChatStatusIdle: "就绪",
ChatStatusYoloIdle: "已跳过批准",
ChatStatusCycleHint: "shift+tab 循环切换",
ChatStatusCacheNowFmt: "本次命中 %s",
ChatStatusCacheAvgFmt: "平均 %s",
ChatStatusPlanApproval: "Enter/y 批准并执行 · n/Esc 继续规划 · PgUp/PgDn 滚动",
PlanApprovalPrompt: "计划已生成(见上方)— Enter/y 批准执行,n/Esc 继续规划",
ChatStatusToolApproval: "1 本次允许 · 2 本会话允许 · 3 总是允许(保存) · 4 拒绝 · y/a/p/n 兼容 · Ctrl-C 取消本轮",
+6 -3
View File
@@ -386,6 +386,8 @@ function post(path, body) {
function scrollDown() { requestAnimationFrame(()=>{log.scrollTop=log.scrollHeight;});}
function hideWelcome() { if(welcome) welcome.style.display='none';}
function fmtTok(n) { return n>=1000?(n/1000).toFixed(1).replace(/\.0$/,'')+'k':String(n);}
function currencySymbol(c){const v=String(c||'¥').trim();if(/^(cny|rmb|yuan)$/i.test(v))return '¥';if(/^(usd|dollar)$/i.test(v))return '$';return v||'¥';}
function fmtMoney(n,c){if(typeof n!=='number'||!isFinite(n))return '—';const s=currencySymbol(c);return s+(n<1?n.toFixed(4):n.toFixed(2));}
function fmtElapsed(ms) { const s=Math.floor(ms/1000); return s<60?s+'s':Math.floor(s/60)+'m '+s%60+'s';}
function escHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function el(tag,cls,text) { const e=document.createElement(tag); if(cls)e.className=cls; if(text)e.textContent=text; return e;}
@@ -610,7 +612,8 @@ es.onmessage=ev=>{setConnState('connected');
const sp=el('span','item');sp.innerHTML=it.l+' <span class="v '+it.c+'">'+it.v+'</span>';strip.appendChild(sp);
});
if(e.usage.cacheHitTokens){const sp=el('span','item');sp.innerHTML='Cache <span class="v acc">'+Math.round(e.usage.cacheHitTokens/Math.max(1,e.usage.cacheHitTokens+e.usage.cacheMissTokens)*100)+'%</span>';strip.appendChild(sp);}
if(e.usage.costUsd){const sp=el('span','item');sp.innerHTML='Cost <span class="v">$'+e.usage.costUsd.toFixed(4)+'</span>';strip.appendChild(sp);}
const usageCost=e.usage.cost??e.usage.costUsd;
if(typeof usageCost==='number'&&usageCost>0){const sp=el('span','item');sp.innerHTML='Cost <span class="v">'+fmtMoney(usageCost,e.usage.currency)+'</span>';strip.appendChild(sp);}
log.appendChild(strip);scrollDown();
} break;
case 'notice': log.appendChild(el('div','notice'+(e.level==='warn'?' notice--warn':''),(e.level==='warn'?'! ':'')+(e.text||'')));scrollDown();break;
@@ -638,8 +641,8 @@ function fetchStatus(){
// sidebar metrics
const cacheTotal=(s.cacheHit||0)+(s.cacheMiss||0);
$('#sm-cache').textContent=cacheTotal>0?Math.round((s.cacheHit||0)/cacheTotal*100)+'%':'—';
if(s.lastUsage?.costUsd!=null)$('#sm-cost').textContent='$'+s.lastUsage.costUsd.toFixed(4);
else if(s.lastUsage?.totalCost!=null)$('#sm-cost').textContent='$'+s.lastUsage.totalCost.toFixed(4);
const lastCost=s.lastUsage?.cost??s.lastUsage?.costUsd??s.lastUsage?.totalCost;
if(typeof lastCost==='number')$('#sm-cost').textContent=fmtMoney(lastCost,s.lastUsage?.currency);
if(s.balance?.display)$('#sm-balance').textContent=s.balance.display;
if(s.balance?.display)balanceInfo.textContent='💰 '+s.balance.display;
}).catch(()=>{});
+9 -2
View File
@@ -72,7 +72,11 @@ type wireUsage struct {
// hit-rate Σhit/Σ(hit+miss), steadier than the single-turn CacheHitTokens.
SessionCacheHitTokens int `json:"sessionCacheHitTokens"`
SessionCacheMissTokens int `json:"sessionCacheMissTokens"`
CostUSD float64 `json:"costUsd,omitempty"`
Cost float64 `json:"cost,omitempty"`
Currency string `json:"currency,omitempty"`
// CostUSD is kept for older status consumers. It mirrors Cost and does not
// imply USD.
CostUSD float64 `json:"costUsd,omitempty"`
}
type wireCacheDiagnostics struct {
@@ -154,7 +158,10 @@ func toWire(e event.Event) wireEvent {
w.Usage.CacheDiagnostics = toWireCacheDiagnostics(e.CacheDiagnostics)
}
if e.Pricing != nil {
w.Usage.CostUSD = e.Pricing.Cost(u)
cost := e.Pricing.Cost(u)
w.Usage.Cost = cost
w.Usage.Currency = e.Pricing.Symbol()
w.Usage.CostUSD = cost
}
}
case event.ApprovalRequest:
+1 -1
View File
@@ -27,7 +27,7 @@ func TestToWire(t *testing.T) {
LogRewriteVersion: 1,
},
})
if w.Usage == nil || w.Usage.TotalTokens != 1200 || w.Usage.CostUSD <= 0 {
if w.Usage == nil || w.Usage.TotalTokens != 1200 || w.Usage.Cost <= 0 || w.Usage.CostUSD <= 0 || w.Usage.Currency != "¥" {
t.Errorf("usage = %+v", w.Usage)
}
if w.Usage.CacheDiagnostics == nil || w.Usage.CacheDiagnostics.PrefixChangeReasons[0] != "log_rewrite" {