feat: shell selector (#27)
This commit is contained in:
@@ -417,7 +417,7 @@ func main() {
|
||||
}
|
||||
|
||||
serviceCfg := orchestrator.DefaultServiceConfig()
|
||||
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, log)
|
||||
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, log)
|
||||
taskSvc.SetExecutionStopper(orchestratorSvc)
|
||||
|
||||
// Set up comment creator for saving agent responses as comments
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -221,6 +222,7 @@ func (m *Manager) Start(ctx context.Context) error {
|
||||
// Auto-create shell session if enabled
|
||||
if m.cfg.ShellEnabled {
|
||||
shellCfg := shell.DefaultConfig(m.cfg.WorkDir)
|
||||
shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv)
|
||||
shellSession, err := shell.NewSession(shellCfg, m.logger)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to create shell session", zap.Error(err))
|
||||
@@ -237,6 +239,23 @@ func (m *Manager) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func preferredShellCommand(env []string) string {
|
||||
if value := lookupEnvValue(env, "AGENTCTL_SHELL_COMMAND"); value != "" {
|
||||
return value
|
||||
}
|
||||
return lookupEnvValue(env, "SHELL")
|
||||
}
|
||||
|
||||
func lookupEnvValue(env []string, key string) string {
|
||||
prefix := key + "="
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
return strings.TrimPrefix(entry, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Configure sets the agent command and optional environment variables.
|
||||
// This must be called before Start() if the instance was created without a command.
|
||||
func (m *Manager) Configure(command string, env map[string]string) error {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -53,9 +54,11 @@ const maxOutputBufferSize = 16 * 1024
|
||||
|
||||
// Config holds shell session configuration
|
||||
type Config struct {
|
||||
WorkDir string // Working directory (default: workspace)
|
||||
Cols int // Initial terminal columns (default: 80)
|
||||
Rows int // Initial terminal rows (default: 24)
|
||||
WorkDir string // Working directory (default: workspace)
|
||||
Cols int // Initial terminal columns (default: 80)
|
||||
Rows int // Initial terminal rows (default: 24)
|
||||
ShellCommand string // Optional shell command override
|
||||
ShellArgs []string // Optional shell args override
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default shell configuration
|
||||
@@ -109,6 +112,14 @@ func detectShell() (string, []string) {
|
||||
// This is called automatically when agentctl starts.
|
||||
func NewSession(cfg Config, log *logger.Logger) (*Session, error) {
|
||||
shell, args := detectShell()
|
||||
if cfg.ShellCommand != "" {
|
||||
shell = cfg.ShellCommand
|
||||
if len(cfg.ShellArgs) > 0 {
|
||||
args = cfg.ShellArgs
|
||||
} else {
|
||||
args = defaultShellArgs(shell)
|
||||
}
|
||||
}
|
||||
|
||||
s := &Session{
|
||||
logger: log.WithFields(zap.String("component", "shell")),
|
||||
@@ -128,6 +139,17 @@ func NewSession(cfg Config, log *logger.Logger) (*Session, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func defaultShellArgs(shellCommand string) []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
lower := strings.ToLower(shellCommand)
|
||||
if strings.Contains(lower, "pwsh") || strings.Contains(lower, "powershell") {
|
||||
return []string{"-NoLogo", "-NoExit"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return []string{"-l"}
|
||||
}
|
||||
|
||||
// start initializes and starts the shell process
|
||||
func (s *Session) start(cfg Config) error {
|
||||
s.mu.Lock()
|
||||
@@ -400,4 +422,3 @@ func buildShellEnv(workDir string) []string {
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -522,7 +522,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
// Create orchestrator service
|
||||
cfg := orchestrator.DefaultServiceConfig()
|
||||
cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests
|
||||
orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, log)
|
||||
orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, nil, log)
|
||||
|
||||
// Create WebSocket gateway
|
||||
gateway := gateways.NewGateway(log)
|
||||
|
||||
@@ -143,6 +143,7 @@ func agentSessionStateToV1(state models.TaskSessionState) v1.TaskSessionState {
|
||||
type Executor struct {
|
||||
agentManager AgentManagerClient
|
||||
repo repository.Repository
|
||||
shellPrefs ShellPreferenceProvider
|
||||
logger *logger.Logger
|
||||
|
||||
// In-memory cache for fast lookups (synced with database)
|
||||
@@ -160,6 +161,11 @@ type Executor struct {
|
||||
type ExecutorConfig struct {
|
||||
MaxConcurrent int // Maximum concurrent agent executions
|
||||
WorktreeEnabled bool // Whether to use Git worktrees for agent isolation
|
||||
ShellPrefs ShellPreferenceProvider
|
||||
}
|
||||
|
||||
type ShellPreferenceProvider interface {
|
||||
PreferredShell(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// NewExecutor creates a new executor
|
||||
@@ -171,6 +177,7 @@ func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, lo
|
||||
return &Executor{
|
||||
agentManager: agentManager,
|
||||
repo: repo,
|
||||
shellPrefs: cfg.ShellPrefs,
|
||||
logger: log.WithFields(zap.String("component", "executor")),
|
||||
executions: make(map[string]*TaskExecution),
|
||||
maxConcurrent: maxConcurrent,
|
||||
@@ -185,6 +192,26 @@ func (e *Executor) SetWorktreeEnabled(enabled bool) {
|
||||
e.worktreeEnabled = enabled
|
||||
}
|
||||
|
||||
func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]string) map[string]string {
|
||||
if e.shellPrefs == nil {
|
||||
return env
|
||||
}
|
||||
preferred, err := e.shellPrefs.PreferredShell(ctx)
|
||||
if err != nil {
|
||||
return env
|
||||
}
|
||||
preferred = strings.TrimSpace(preferred)
|
||||
if preferred == "" {
|
||||
return env
|
||||
}
|
||||
if env == nil {
|
||||
env = make(map[string]string)
|
||||
}
|
||||
env["AGENTCTL_SHELL_COMMAND"] = preferred
|
||||
env["SHELL"] = preferred
|
||||
return env
|
||||
}
|
||||
|
||||
// LoadActiveSessionsFromDB loads active agent sessions from the database into memory
|
||||
// This should be called on startup to restore state after a restart
|
||||
func (e *Executor) LoadActiveSessionsFromDB(ctx context.Context) error {
|
||||
@@ -354,6 +381,8 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
|
||||
zap.String("agent_profile_id", agentProfileID),
|
||||
zap.Bool("use_worktree", req.UseWorktree))
|
||||
|
||||
req.Env = e.applyPreferredShellEnv(ctx, req.Env)
|
||||
|
||||
// Call the AgentManager to launch the container
|
||||
resp, err := e.agentManager.LaunchAgent(ctx, req)
|
||||
if err != nil {
|
||||
@@ -544,6 +573,8 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo
|
||||
zap.String("acp_session_id", req.ACPSessionID),
|
||||
zap.Bool("use_worktree", req.UseWorktree))
|
||||
|
||||
req.Env = e.applyPreferredShellEnv(ctx, req.Env)
|
||||
|
||||
resp, err := e.agentManager.LaunchAgent(ctx, req)
|
||||
if err != nil {
|
||||
e.logger.Error("failed to relaunch agent for session",
|
||||
|
||||
@@ -116,6 +116,7 @@ func NewService(
|
||||
agentManager executor.AgentManagerClient,
|
||||
taskRepo scheduler.TaskRepository,
|
||||
repo repository.Repository,
|
||||
shellPrefs executor.ShellPreferenceProvider,
|
||||
log *logger.Logger,
|
||||
) *Service {
|
||||
svcLogger := log.WithFields(zap.String("component", "orchestrator"))
|
||||
@@ -127,6 +128,7 @@ func NewService(
|
||||
execCfg := executor.ExecutorConfig{
|
||||
MaxConcurrent: cfg.Scheduler.MaxConcurrent,
|
||||
WorktreeEnabled: cfg.WorktreeEnabled,
|
||||
ShellPrefs: shellPrefs,
|
||||
}
|
||||
exec := executor.NewExecutor(agentManager, repo, log, execCfg)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
"github.com/kandev/kandev/internal/user/dto"
|
||||
"github.com/kandev/kandev/internal/user/service"
|
||||
@@ -35,7 +36,10 @@ func (c *Controller) GetUserSettings(ctx context.Context) (dto.UserSettingsRespo
|
||||
if err != nil {
|
||||
return dto.UserSettingsResponse{}, err
|
||||
}
|
||||
return dto.UserSettingsResponse{Settings: dto.FromUserSettings(settings)}, nil
|
||||
return dto.UserSettingsResponse{
|
||||
Settings: dto.FromUserSettings(settings),
|
||||
ShellOptions: shellOptionsForOS(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) {
|
||||
@@ -44,9 +48,34 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
|
||||
BoardID: req.BoardID,
|
||||
RepositoryIDs: req.RepositoryIDs,
|
||||
InitialSetupComplete: req.InitialSetupComplete,
|
||||
PreferredShell: req.PreferredShell,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.UserSettingsResponse{}, err
|
||||
}
|
||||
return dto.UserSettingsResponse{Settings: dto.FromUserSettings(settings)}, nil
|
||||
return dto.UserSettingsResponse{
|
||||
Settings: dto.FromUserSettings(settings),
|
||||
ShellOptions: shellOptionsForOS(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shellOptionsForOS() []dto.ShellOption {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return []dto.ShellOption{
|
||||
{Value: "auto", Label: "System default"},
|
||||
{Value: "pwsh.exe", Label: "PowerShell (pwsh)"},
|
||||
{Value: "powershell.exe", Label: "Windows PowerShell"},
|
||||
{Value: "cmd.exe", Label: "Command Prompt"},
|
||||
{Value: "custom", Label: "Custom"},
|
||||
}
|
||||
default:
|
||||
return []dto.ShellOption{
|
||||
{Value: "auto", Label: "System default"},
|
||||
{Value: "/bin/zsh", Label: "zsh"},
|
||||
{Value: "/bin/bash", Label: "bash"},
|
||||
{Value: "/bin/sh", Label: "sh"},
|
||||
{Value: "custom", Label: "Custom"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type UserSettingsDTO struct {
|
||||
BoardID string `json:"board_id"`
|
||||
RepositoryIDs []string `json:"repository_ids"`
|
||||
InitialSetupComplete bool `json:"initial_setup_complete"`
|
||||
PreferredShell string `json:"preferred_shell"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -28,7 +29,13 @@ type UserResponse struct {
|
||||
}
|
||||
|
||||
type UserSettingsResponse struct {
|
||||
Settings UserSettingsDTO `json:"settings"`
|
||||
Settings UserSettingsDTO `json:"settings"`
|
||||
ShellOptions []ShellOption `json:"shell_options"`
|
||||
}
|
||||
|
||||
type ShellOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateUserSettingsRequest struct {
|
||||
@@ -36,6 +43,7 @@ type UpdateUserSettingsRequest struct {
|
||||
BoardID *string `json:"board_id,omitempty"`
|
||||
RepositoryIDs *[]string `json:"repository_ids,omitempty"`
|
||||
InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
|
||||
PreferredShell *string `json:"preferred_shell,omitempty"`
|
||||
}
|
||||
|
||||
func FromUser(user *models.User) UserDTO {
|
||||
@@ -54,6 +62,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
|
||||
BoardID: settings.BoardID,
|
||||
RepositoryIDs: settings.RepositoryIDs,
|
||||
InitialSetupComplete: settings.InitialSetupComplete,
|
||||
PreferredShell: settings.PreferredShell,
|
||||
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ type httpUpdateUserSettingsRequest struct {
|
||||
BoardID *string `json:"board_id,omitempty"`
|
||||
RepositoryIDs *[]string `json:"repository_ids,omitempty"`
|
||||
InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
|
||||
PreferredShell *string `json:"preferred_shell,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
|
||||
@@ -81,6 +82,7 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
|
||||
BoardID: body.BoardID,
|
||||
RepositoryIDs: body.RepositoryIDs,
|
||||
InitialSetupComplete: body.InitialSetupComplete,
|
||||
PreferredShell: body.PreferredShell,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update user settings", zap.Error(err))
|
||||
@@ -103,6 +105,7 @@ type wsUpdateUserSettingsRequest struct {
|
||||
BoardID *string `json:"board_id,omitempty"`
|
||||
RepositoryIDs *[]string `json:"repository_ids,omitempty"`
|
||||
InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
|
||||
PreferredShell *string `json:"preferred_shell,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -115,6 +118,7 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*
|
||||
BoardID: req.BoardID,
|
||||
RepositoryIDs: req.RepositoryIDs,
|
||||
InitialSetupComplete: req.InitialSetupComplete,
|
||||
PreferredShell: req.PreferredShell,
|
||||
})
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil)
|
||||
|
||||
@@ -15,6 +15,7 @@ type UserSettings struct {
|
||||
BoardID string `json:"board_id"`
|
||||
RepositoryIDs []string `json:"repository_ids"`
|
||||
InitialSetupComplete bool `json:"initial_setup_complete"`
|
||||
PreferredShell string `json:"preferred_shell"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
@@ -27,6 +28,7 @@ type UpdateUserSettingsRequest struct {
|
||||
BoardID *string
|
||||
RepositoryIDs *[]string
|
||||
InitialSetupComplete *bool
|
||||
PreferredShell *string
|
||||
}
|
||||
|
||||
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
|
||||
@@ -54,6 +56,14 @@ func (s *Service) GetUserSettings(ctx context.Context) (*models.UserSettings, er
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Service) PreferredShell(ctx context.Context) (string, error) {
|
||||
settings, err := s.repo.GetUserSettings(ctx, s.defaultUser)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return settings.PreferredShell, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) {
|
||||
settings, err := s.repo.GetUserSettings(ctx, s.defaultUser)
|
||||
if err != nil {
|
||||
@@ -71,6 +81,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting
|
||||
if req.InitialSetupComplete != nil {
|
||||
settings.InitialSetupComplete = *req.InitialSetupComplete
|
||||
}
|
||||
if req.PreferredShell != nil {
|
||||
settings.PreferredShell = strings.TrimSpace(*req.PreferredShell)
|
||||
}
|
||||
settings.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpsertUserSettings(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
@@ -89,6 +102,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
|
||||
"board_id": settings.BoardID,
|
||||
"repository_ids": settings.RepositoryIDs,
|
||||
"initial_setup_complete": settings.InitialSetupComplete,
|
||||
"preferred_shell": settings.PreferredShell,
|
||||
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
|
||||
|
||||
@@ -191,6 +191,7 @@ func (r *SQLiteRepository) UpsertUserSettings(ctx context.Context, settings *mod
|
||||
"board_id": settings.BoardID,
|
||||
"repository_ids": settings.RepositoryIDs,
|
||||
"initial_setup_complete": settings.InitialSetupComplete,
|
||||
"preferred_shell": settings.PreferredShell,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -233,6 +234,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
BoardID string `json:"board_id"`
|
||||
RepositoryIDs []string `json:"repository_ids"`
|
||||
InitialSetupComplete bool `json:"initial_setup_complete"`
|
||||
PreferredShell string `json:"preferred_shell"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -241,5 +243,6 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
settings.BoardID = payload.BoardID
|
||||
settings.RepositoryIDs = payload.RepositoryIDs
|
||||
settings.InitialSetupComplete = payload.InitialSetupComplete
|
||||
settings.PreferredShell = payload.PreferredShell
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ type User struct {
|
||||
}
|
||||
|
||||
type UserSettings struct {
|
||||
UserID string `json:"user_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
RepositoryIDs []string `json:"repository_ids"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UserID string `json:"user_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
RepositoryIDs []string `json:"repository_ids"`
|
||||
PreferredShell string `json:"preferred_shell"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
workspaceId: settingsWorkspaceId,
|
||||
boardId: settingsBoardId,
|
||||
repositoryIds: settingsRepositoryIds,
|
||||
preferredShell: userSettings?.preferred_shell || null,
|
||||
loaded: Boolean(userSettings),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { IconPalette, IconCode, IconServer } from '@tabler/icons-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
import { fetchUserSettings, updateUserSettings } from '@/lib/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import type { Theme, Editor } from '@/lib/settings/types';
|
||||
@@ -20,6 +24,30 @@ export default function GeneralSettingsPage() {
|
||||
SETTINGS_DATA.general.customEditorCommand || ''
|
||||
);
|
||||
const [backendUrl] = useState<string>(() => getBackendConfig().apiBaseUrl);
|
||||
const [preferredShell, setPreferredShell] = useState('');
|
||||
const [customShell, setCustomShell] = useState('');
|
||||
const [shellSelection, setShellSelection] = useState('auto');
|
||||
const [baselineShell, setBaselineShell] = useState('');
|
||||
const [shellLoaded, setShellLoaded] = useState(false);
|
||||
const [shellOptions, setShellOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
||||
const storeApi = useAppStoreApi();
|
||||
const saveShellRequest = useRequest(async () => {
|
||||
const trimmed = preferredShell.trim();
|
||||
const currentSettings = storeApi.getState().userSettings;
|
||||
await updateUserSettings({
|
||||
workspace_id: currentSettings.workspaceId ?? '',
|
||||
board_id: currentSettings.boardId ?? '',
|
||||
repository_ids: currentSettings.repositoryIds,
|
||||
preferred_shell: trimmed,
|
||||
});
|
||||
setBaselineShell(trimmed);
|
||||
setUserSettings({
|
||||
...currentSettings,
|
||||
preferredShell: trimmed || null,
|
||||
loaded: true,
|
||||
});
|
||||
});
|
||||
const mounted = useSyncExternalStore(
|
||||
() => () => undefined,
|
||||
() => true,
|
||||
@@ -27,6 +55,45 @@ export default function GeneralSettingsPage() {
|
||||
);
|
||||
|
||||
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
const shellDirty = preferredShell.trim() !== baselineShell.trim();
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
fetchUserSettings({ cache: 'no-store' })
|
||||
.then((data) => {
|
||||
if (!isActive || !data?.settings) return;
|
||||
const shellValue = data.settings.preferred_shell || '';
|
||||
setPreferredShell(shellValue);
|
||||
setBaselineShell(shellValue);
|
||||
const nextShellOptions = data.shell_options ?? [];
|
||||
setShellOptions(nextShellOptions);
|
||||
if (shellValue === '') {
|
||||
setShellSelection('auto');
|
||||
setCustomShell('');
|
||||
} else if (nextShellOptions.some((option) => option.value === shellValue)) {
|
||||
setShellSelection(shellValue);
|
||||
setCustomShell('');
|
||||
} else {
|
||||
setShellSelection('custom');
|
||||
setCustomShell(shellValue);
|
||||
}
|
||||
setShellLoaded(true);
|
||||
const currentSettings = storeApi.getState().userSettings;
|
||||
setUserSettings({
|
||||
...currentSettings,
|
||||
preferredShell: shellValue || null,
|
||||
loaded: true,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (isActive) {
|
||||
setShellLoaded(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [setUserSettings, storeApi]);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
@@ -121,6 +188,85 @@ export default function GeneralSettingsPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconCode className="h-5 w-5" />}
|
||||
title="Shell"
|
||||
description="Pick the default shell for task sessions"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="text-base">Preferred Shell</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => saveShellRequest.run()}
|
||||
disabled={!shellLoaded || !shellDirty || saveShellRequest.isLoading}
|
||||
>
|
||||
{saveShellRequest.isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={shellSelection}
|
||||
onValueChange={(value) => {
|
||||
setShellSelection(value);
|
||||
if (value === 'auto') {
|
||||
setPreferredShell('');
|
||||
setCustomShell('');
|
||||
return;
|
||||
}
|
||||
if (value === 'custom') {
|
||||
setPreferredShell(customShell);
|
||||
return;
|
||||
}
|
||||
setPreferredShell(value);
|
||||
setCustomShell('');
|
||||
}}
|
||||
disabled={!shellLoaded || shellOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
shellOptions.length === 0
|
||||
? 'Shell options unavailable'
|
||||
: 'Select a shell'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shellOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{shellSelection === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={customShell}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setCustomShell(nextValue);
|
||||
setPreferredShell(nextValue);
|
||||
}}
|
||||
placeholder="/bin/zsh"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter a shell path or command available in the agent environment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
New task sessions will use this shell. Existing sessions keep their current shell.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconServer className="h-5 w-5" />}
|
||||
title="Backend Connection"
|
||||
|
||||
@@ -9,6 +9,7 @@ type DisplaySettings = {
|
||||
workspaceId: string | null;
|
||||
boardId: string | null;
|
||||
repositoryIds: string[];
|
||||
preferredShell: string | null;
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
@@ -23,6 +24,7 @@ type CommitPayload = {
|
||||
workspaceId: string | null;
|
||||
boardId: string | null;
|
||||
repositoryIds: string[];
|
||||
preferredShell?: string | null;
|
||||
};
|
||||
|
||||
export function useUserDisplaySettings({
|
||||
@@ -38,7 +40,13 @@ export function useUserDisplaySettings({
|
||||
const commitSettings = useCallback(
|
||||
(next: CommitPayload) => {
|
||||
const repositoryIds = Array.from(new Set(next.repositoryIds)).sort();
|
||||
const normalized: DisplaySettings = { ...next, repositoryIds, loaded: true };
|
||||
const normalized: DisplaySettings = {
|
||||
workspaceId: next.workspaceId,
|
||||
boardId: next.boardId,
|
||||
repositoryIds,
|
||||
preferredShell: next.preferredShell ?? userSettings.preferredShell ?? null,
|
||||
loaded: true,
|
||||
};
|
||||
const sameWorkspace = normalized.workspaceId === userSettings.workspaceId;
|
||||
const sameBoard = normalized.boardId === userSettings.boardId;
|
||||
const sameRepos =
|
||||
@@ -77,6 +85,7 @@ export function useUserDisplaySettings({
|
||||
workspaceId: data.settings.workspace_id || null,
|
||||
boardId: data.settings.board_id || null,
|
||||
repositoryIds,
|
||||
preferredShell: data.settings.preferred_shell || null,
|
||||
loaded: true,
|
||||
});
|
||||
})
|
||||
|
||||
@@ -156,7 +156,12 @@ export async function fetchUserSettings(options?: ApiRequestOptions) {
|
||||
}
|
||||
|
||||
export async function updateUserSettings(
|
||||
payload: { workspace_id: string; board_id: string; repository_ids: string[] },
|
||||
payload: {
|
||||
workspace_id: string;
|
||||
board_id: string;
|
||||
repository_ids: string[];
|
||||
preferred_shell?: string;
|
||||
},
|
||||
options?: ApiRequestOptions
|
||||
) {
|
||||
return fetchJson<UserSettingsResponse>('/api/v1/user/settings', {
|
||||
|
||||
@@ -105,6 +105,7 @@ export type UserSettingsState = {
|
||||
workspaceId: string | null;
|
||||
boardId: string | null;
|
||||
repositoryIds: string[];
|
||||
preferredShell: string | null;
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
@@ -281,7 +282,13 @@ const defaultState: AppState = {
|
||||
tasks: { activeTaskId: null },
|
||||
agents: { agents: [] },
|
||||
agentProfiles: { items: [], version: 0 },
|
||||
userSettings: { workspaceId: null, boardId: null, repositoryIds: [], loaded: false },
|
||||
userSettings: {
|
||||
workspaceId: null,
|
||||
boardId: null,
|
||||
repositoryIds: [],
|
||||
preferredShell: null,
|
||||
loaded: false,
|
||||
},
|
||||
terminal: { terminals: [] },
|
||||
shell: { outputs: {}, statuses: {} },
|
||||
diffs: { files: [] },
|
||||
|
||||
@@ -231,6 +231,7 @@ export type UserSettingsUpdatedPayload = {
|
||||
board_id: string;
|
||||
repository_ids: string[];
|
||||
initial_setup_complete?: boolean;
|
||||
preferred_shell?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -172,11 +172,13 @@ export type UserSettings = {
|
||||
board_id: string;
|
||||
repository_ids: string[];
|
||||
initial_setup_complete?: boolean;
|
||||
preferred_shell?: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UserSettingsResponse = {
|
||||
settings: UserSettings;
|
||||
shell_options?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
export type UserResponse = {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function registerUsersHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
workspaceId: message.payload.workspace_id || null,
|
||||
boardId: message.payload.board_id || null,
|
||||
repositoryIds,
|
||||
preferredShell: message.payload.preferred_shell || null,
|
||||
loaded: true,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user