mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
55915d9941
commit
b87a878cad
83 files changed
+15225
-3426
No files matched your search
@@ -54,3 +54,7 @@ node_modules/
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Scratch / agent working dirs (never committed)
|
||||
/tmp/
|
||||
/.codex/
|
||||
+1114
-522
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+894
-435
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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function asArray<T>(value: T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
+731
-135
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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
|
||||
|
||||
+2915
-671
File diff suppressed because it is too large.
Load diff
+1
-2
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 取消本轮",
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
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(()=>{});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in new issue
Block a user