Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 97 additions & 20 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ func (a *App) restoreOrBuildTabs() {
}
tab.model = entry.Model
tab.effort = cloneStringPtr(entry.Effort)
tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
tab.mode = persistedTabMode(entry.Mode)
tab.goal = strings.TrimSpace(entry.Goal)
tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
Expand Down Expand Up @@ -445,6 +446,7 @@ func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *Wo
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
tokenMode: boot.TokenModeFull,
mode: "normal",
toolApprovalMode: control.ToolApprovalAsk,
disabledMCP: map[string]ServerView{},
Expand Down Expand Up @@ -1820,16 +1822,18 @@ func (a *App) jobsForCtrl(ctrl *control.Controller, out []JobView) []JobView {

// Meta describes the session for the frontend's header and status line.
type Meta struct {
Label string `json:"label"`
Ready bool `json:"ready"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
Cwd string `json:"cwd"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
ToolApprovalMode string `json:"toolApprovalMode"`
Goal string `json:"goal,omitempty"`
GoalStatus string `json:"goalStatus,omitempty"`
Label string `json:"label"`
Ready bool `json:"ready"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
Cwd string `json:"cwd"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
TokenMode string `json:"tokenMode"`
Goal string `json:"goal,omitempty"`
GoalStatus string `json:"goalStatus,omitempty"`
}

// Meta reports the model label, readiness, any startup error, the working
Expand All @@ -1849,20 +1853,24 @@ func (a *App) MetaForTab(tabID string) Meta {
cwd, _ = os.Getwd()
}
autoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()
collaborationMode := currentTabCollaborationMode(tab)
toolApprovalMode := currentTabToolApprovalMode(tab)
tokenMode := currentTabTokenMode(tab)
goal := currentTabGoal(tab)
goalStatus := currentTabGoalStatus(tab)
return Meta{
Label: tab.Label,
Ready: tab.Ready,
StartupErr: tab.StartupErr,
EventChannel: eventChannel,
Cwd: cwd,
AutoApproveTools: autoApproveTools,
Bypass: autoApproveTools,
ToolApprovalMode: toolApprovalMode,
Goal: goal,
GoalStatus: goalStatus,
Label: tab.Label,
Ready: tab.Ready,
StartupErr: tab.StartupErr,
EventChannel: eventChannel,
Cwd: cwd,
AutoApproveTools: autoApproveTools,
Bypass: autoApproveTools,
CollaborationMode: collaborationMode,
ToolApprovalMode: toolApprovalMode,
TokenMode: tokenMode,
Goal: goal,
GoalStatus: goalStatus,
}
}

Expand Down Expand Up @@ -3378,6 +3386,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
WorkspaceRoot: tab.WorkspaceRoot,
SessionDir: tabSessionDir(tab),
EffortOverride: cloneStringPtr(effortOverride),
TokenMode: currentTabTokenMode(tab),
})
if err != nil {
return err
Expand Down Expand Up @@ -3472,6 +3481,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
WorkspaceRoot: tab.WorkspaceRoot,
SessionDir: tabSessionDir(tab),
EffortOverride: &effort,
TokenMode: currentTabTokenMode(tab),
})
if err != nil {
return err
Expand Down Expand Up @@ -3499,6 +3509,73 @@ func (a *App) SetEffortForTab(tabID, level string) error {
return nil
}

func (a *App) SetTokenMode(mode string) error {
return a.SetTokenModeForTab("", mode)
}

func (a *App) SetTokenModeForTab(tabID, mode string) error {
mode = boot.NormalizeTokenMode(mode)
tab := a.tabByID(tabID)
if tab == nil {
if strings.TrimSpace(tabID) == "" {
return nil
}
return fmt.Errorf("tab %q not found", tabID)
}
if mode == currentTabTokenMode(tab) {
return nil
}
ctrl := tab.Ctrl
if ctrl != nil && ctrl.Running() {
return fmt.Errorf("finish or cancel the current turn before changing token mode")
}

var carried []provider.Message
prevPath := ""
oldCtrl := tab.Ctrl
if oldCtrl != nil {
prevPath = oldCtrl.SessionPath()
_ = oldCtrl.Snapshot()
carried = oldCtrl.History()
}
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: tab.model,
RequireKey: false,
Sink: tab.sink,
WorkspaceRoot: tab.WorkspaceRoot,
SessionDir: tabSessionDir(tab),
EffortOverride: cloneStringPtr(tab.effort),
TokenMode: mode,
})
if err != nil {
return err
}
a.bindControllerDisplayRecorder(newCtrl)
if oldCtrl != nil {
oldCtrl.Close()
}
a.mu.Lock()
tab.Ctrl = newCtrl
tab.tokenMode = mode
tab.Label = newCtrl.Label()
tab.StartupErr = ""
tab.Ready = true
a.saveTabsLocked()
a.mu.Unlock()
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, tab.mode)
applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)
newCtrl.SetGoal(tab.goal)
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
if len(carried) > 0 {
newCtrl.Resume(&agent.Session{Messages: carried}, path)
} else if path != "" {
newCtrl.SetSessionPath(path)
}
a.persistTabSessionPath(tab, path)
return nil
}

func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {
return a.applyConfigChange(func(cfg *config.Config) error {
if _, ok := cfg.Provider(entry.Name); !ok {
Expand Down
90 changes: 90 additions & 0 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,78 @@ func TestSetEffortRebuildsController(t *testing.T) {
}
}

func TestSetTokenModeRebuildsController(t *testing.T) {
isolateDesktopUserDirs(t)

app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
old := control.New(control.Options{Label: "old-controller"})
app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()

if err := app.SetTokenMode("economy"); err != nil {
t.Fatalf("SetTokenMode(economy): %v", err)
}
if c := app.activeCtrl(); c == nil {
t.Fatal("SetTokenMode should leave a rebuilt controller")
}
if c := app.activeCtrl(); c == old {
t.Fatal("SetTokenMode should rebuild the active controller so the provider sees the new tool profile")
}
tab := app.activeTab()
if tab == nil {
t.Fatal("active tab missing")
}
if got := currentTabTokenMode(tab); got != "economy" {
t.Fatalf("token mode = %q, want economy", got)
}
if got := app.Meta().TokenMode; got != "economy" {
t.Fatalf("Meta token mode = %q, want economy", got)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" {
t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs)
}
}

func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
isolateDesktopUserDirs(t)

app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
old := control.New(control.Options{Label: "old-controller"})
app.setTestCtrl(old, "missing-token-mode-model")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()

err := app.SetTokenMode("economy")
if err == nil {
t.Fatal("SetTokenMode(economy) with an unknown model should fail")
}
if c := app.activeCtrl(); c != old {
t.Fatalf("SetTokenMode failure replaced controller: got %p want %p", c, old)
}
tab := app.activeTab()
if tab == nil {
t.Fatal("active tab missing")
}
if got := currentTabTokenMode(tab); got != "full" {
t.Fatalf("token mode after failed rebuild = %q, want full", got)
}
if got := app.Meta().TokenMode; got != "full" {
t.Fatalf("Meta token mode after failed rebuild = %q, want full", got)
}
}

func TestSetEffortRejectsRunningTurn(t *testing.T) {
isolateDesktopUserDirs(t)

Expand All @@ -993,6 +1065,24 @@ func TestSetEffortRejectsRunningTurn(t *testing.T) {
waitNotRunning(t, app.activeCtrl())
}

func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
isolateDesktopUserDirs(t)

runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
app.activeCtrl().Submit("work")
<-runner.started

err := app.SetTokenMode("economy")
if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err)
}

close(runner.release)
waitNotRunning(t, app.activeCtrl())
}

func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/goal-draft-mode.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts",
"test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/goal-draft-mode.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts"
"test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts"
},
"dependencies": {
"@tanstack/react-virtual": "^3.14.2",
Expand Down
Loading
Loading