From aebcf5283f432f42e117091abc4b234c3d230255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 13 Jan 2026 22:37:36 +0000 Subject: [PATCH] feat: display settings in kanban page --- apps/backend/Makefile | 2 +- apps/backend/cmd/kandev/main.go | 17 ++ apps/backend/dev/seed.sql | 25 +- apps/backend/internal/events/types.go | 5 + .../internal/gateway/websocket/client.go | 61 +++- .../backend/internal/gateway/websocket/hub.go | 74 ++++- .../gateway/websocket/user_notifications.go | 67 +++++ .../internal/integration/integration_test.go | 6 + .../internal/integration/orchestrator_test.go | 2 + .../internal/task/handlers/task_handlers.go | 7 + apps/backend/internal/task/service/service.go | 7 + .../internal/task/service/service_test.go | 1 + .../internal/user/controller/controller.go | 51 ++++ apps/backend/internal/user/dto/dto.go | 56 ++++ .../internal/user/handlers/handlers.go | 119 ++++++++ apps/backend/internal/user/models/models.go | 19 ++ apps/backend/internal/user/service/service.go | 92 ++++++ apps/backend/internal/user/store/sqlite.go | 238 +++++++++++++++ apps/backend/internal/user/store/store.go | 15 + apps/backend/pkg/api/v1/user.go | 9 +- apps/backend/pkg/websocket/actions.go | 7 + apps/web/app/page.tsx | 22 +- apps/web/components/kanban-board-grid.tsx | 88 ++++++ apps/web/components/kanban-board-header.tsx | 154 ++++++++++ apps/web/components/kanban-board.tsx | 270 +++++++++--------- apps/web/components/kanban-card.tsx | 1 + apps/web/components/task-create-dialog.tsx | 5 +- apps/web/hooks/use-user-display-settings.ts | 178 ++++++++++++ apps/web/lib/kanban/filters.ts | 32 +++ apps/web/lib/ssr/http.ts | 12 +- apps/web/lib/ssr/mapper.ts | 1 + apps/web/lib/state/store.ts | 19 ++ apps/web/lib/types/backend.ts | 13 +- apps/web/lib/types/http.ts | 24 ++ apps/web/lib/ws/client.ts | 33 +++ apps/web/lib/ws/router.ts | 15 + apps/web/lib/ws/use-websocket.tsx | 1 + 37 files changed, 1587 insertions(+), 161 deletions(-) create mode 100644 apps/backend/internal/gateway/websocket/user_notifications.go create mode 100644 apps/backend/internal/user/controller/controller.go create mode 100644 apps/backend/internal/user/dto/dto.go create mode 100644 apps/backend/internal/user/handlers/handlers.go create mode 100644 apps/backend/internal/user/models/models.go create mode 100644 apps/backend/internal/user/service/service.go create mode 100644 apps/backend/internal/user/store/sqlite.go create mode 100644 apps/backend/internal/user/store/store.go create mode 100644 apps/web/components/kanban-board-grid.tsx create mode 100644 apps/web/components/kanban-board-header.tsx create mode 100644 apps/web/hooks/use-user-display-settings.ts create mode 100644 apps/web/lib/kanban/filters.ts diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 68fd02939e..966ef0fd5d 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -69,7 +69,7 @@ dev-db-reset: ## Run tests test: @echo "Running tests..." - $(GO) test -v ./... + $(GO) test ./... ## Run tests with coverage test-coverage: diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 10fd6d3150..b42db0d862 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -50,6 +50,10 @@ import ( taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" + usercontroller "github.com/kandev/kandev/internal/user/controller" + userhandlers "github.com/kandev/kandev/internal/user/handlers" + userservice "github.com/kandev/kandev/internal/user/service" + userstore "github.com/kandev/kandev/internal/user/store" // ACP protocol "github.com/kandev/kandev/pkg/acp/protocol" @@ -140,12 +144,21 @@ func main() { } defer agentSettingsRepo.Close() + userRepo, err := userstore.NewSQLiteRepository(dbPath) + if err != nil { + log.Fatal("Failed to initialize user store", zap.Error(err), zap.String("db_path", dbPath)) + } + defer userRepo.Close() + discoveryRegistry, err := discovery.LoadRegistry() if err != nil { log.Fatal("Failed to load agent discovery config", zap.Error(err)) } agentSettingsController := agentsettingscontroller.NewController(agentSettingsRepo, discoveryRegistry, log) + userSvc := userservice.NewService(userRepo, eventBus, log) + userCtrl := usercontroller.NewController(userSvc) + taskSvc := taskservice.NewService( taskRepo, eventBus, @@ -294,6 +307,7 @@ func main() { // Start the WebSocket hub go gateway.Hub.Run(ctx) gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) + gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log) // Set up historical logs provider for task subscriptions // Uses comments instead of execution logs - all agent messages are now comments @@ -533,6 +547,9 @@ func main() { agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log) log.Info("Registered Agent Settings handlers (HTTP)") + userhandlers.RegisterRoutes(router, gateway.Dispatcher, userCtrl, log) + log.Info("Registered User handlers (HTTP + WebSocket)") + // Health check (simple HTTP for load balancers/monitoring) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/apps/backend/dev/seed.sql b/apps/backend/dev/seed.sql index d9a8689b7a..ab4611c7c5 100644 --- a/apps/backend/dev/seed.sql +++ b/apps/backend/dev/seed.sql @@ -5,6 +5,14 @@ INSERT OR REPLACE INTO workspaces (id, name, description, owner_id, created_at, updated_at) VALUES ('default', 'Default Workspace', 'Your default workspace', '', datetime('now'), datetime('now')); +-- Default user and settings +INSERT OR REPLACE INTO users (id, email, settings, created_at, updated_at) +VALUES ('default-user', 'default@kandev.local', '{"workspace_id":"default","board_id":"dev-board-1","repository_ids":[]}', datetime('now'), datetime('now')); + +-- Default repository (for task creation) +INSERT OR REPLACE INTO repositories (id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at) +VALUES ('default-repo', 'default', 'kandev', 'local', '$KANDEV_REPO_PATH', '', '', '', '', 'main', '', '', datetime('now'), datetime('now')); + -- Sample board INSERT OR REPLACE INTO boards (id, workspace_id, name, description, created_at, updated_at) VALUES @@ -33,12 +41,12 @@ VALUES INSERT OR REPLACE INTO tasks (id, board_id, column_id, title, description, state, priority, position, agent_type, repository_url, branch, metadata, created_at, updated_at) VALUES -- Backlog tasks (no agent assigned) - ('task-001', 'dev-board-1', 'col-backlog', 'Add dark mode support', 'Implement a dark theme toggle for the UI', 'TODO', 3, 0, '', '', '', '{}', datetime('now'), datetime('now')), - ('task-002', 'dev-board-1', 'col-backlog', 'Implement search functionality', 'Add full-text search for tasks across all boards', 'TODO', 2, 1, '', '', '', '{}', datetime('now'), datetime('now')), + ('task-001', 'dev-board-1', 'col-backlog', 'Add dark mode support', 'Implement a dark theme toggle for the UI', 'TODO', 3, 0, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now'), datetime('now')), + ('task-002', 'dev-board-1', 'col-backlog', 'Implement search functionality', 'Add full-text search for tasks across all boards', 'TODO', 2, 1, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now'), datetime('now')), -- To Do tasks (agent assigned with repository) ('task-003', 'dev-board-1', 'col-todo', 'Fix WebSocket reconnection', 'Handle WebSocket disconnects gracefully and auto-reconnect', 'TODO', 5, 0, 'augment-agent', '$KANDEV_REPO_PATH', 'main', '{}', datetime('now'), datetime('now')), - ('task-004', 'dev-board-1', 'col-todo', 'Add task labels/tags', 'Allow users to add colored labels to tasks for categorization', 'TODO', 2, 1, '', '', '', '{}', datetime('now'), datetime('now')), + ('task-004', 'dev-board-1', 'col-todo', 'Add task labels/tags', 'Allow users to add colored labels to tasks for categorization', 'TODO', 2, 1, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now'), datetime('now')), -- In Progress tasks (agent with repository) ('task-005', 'dev-board-1', 'col-inprogress', 'Agent session persistence', 'Persist agent sessions in database instead of memory', 'IN_PROGRESS', 8, 0, 'augment-agent', '$KANDEV_REPO_PATH', 'main', '{}', datetime('now'), datetime('now')), @@ -47,15 +55,15 @@ VALUES ('task-006', 'dev-board-1', 'col-review', 'Comment system improvements', 'Enhanced comment display with markdown and tool calls', 'REVIEW', 6, 0, 'augment-agent', '$KANDEV_REPO_PATH', 'main', '{}', datetime('now'), datetime('now')), -- Done tasks - ('task-007', 'dev-board-1', 'col-done', 'Basic kanban board UI', 'Create the initial kanban board with drag-and-drop', 'COMPLETED', 10, 0, '', '', '', '{}', datetime('now', '-3 days'), datetime('now', '-1 day')), - ('task-008', 'dev-board-1', 'col-done', 'WebSocket API implementation', 'Implement real-time WebSocket communication', 'COMPLETED', 9, 1, '', '', '', '{}', datetime('now', '-5 days'), datetime('now', '-2 days')); + ('task-007', 'dev-board-1', 'col-done', 'Basic kanban board UI', 'Create the initial kanban board with drag-and-drop', 'COMPLETED', 10, 0, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now', '-3 days'), datetime('now', '-1 day')), + ('task-008', 'dev-board-1', 'col-done', 'WebSocket API implementation', 'Implement real-time WebSocket communication', 'COMPLETED', 9, 1, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now', '-5 days'), datetime('now', '-2 days')); -- Sample tasks for dev-board-2 (Feature Backlog) INSERT OR REPLACE INTO tasks (id, board_id, column_id, title, description, state, priority, position, agent_type, repository_url, branch, metadata, created_at, updated_at) VALUES - ('task-101', 'dev-board-2', 'col-ideas', 'Multi-user collaboration', 'Allow multiple users to work on the same board', 'TODO', 5, 0, '', '', '', '{}', datetime('now'), datetime('now')), - ('task-102', 'dev-board-2', 'col-ideas', 'Task dependencies', 'Add ability to link tasks as dependencies', 'TODO', 4, 1, '', '', '', '{}', datetime('now'), datetime('now')), - ('task-103', 'dev-board-2', 'col-planned', 'Agent type marketplace', 'Browse and install different agent types', 'TODO', 6, 0, '', '', '', '{}', datetime('now'), datetime('now')); + ('task-101', 'dev-board-2', 'col-ideas', 'Multi-user collaboration', 'Allow multiple users to work on the same board', 'TODO', 5, 0, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now'), datetime('now')), + ('task-102', 'dev-board-2', 'col-ideas', 'Task dependencies', 'Add ability to link tasks as dependencies', 'TODO', 4, 1, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now'), datetime('now')), + ('task-103', 'dev-board-2', 'col-planned', 'Agent type marketplace', 'Browse and install different agent types', 'TODO', 6, 0, '', '$KANDEV_REPO_PATH', '', '{}', datetime('now'), datetime('now')); -- Sample comments on tasks INSERT OR REPLACE INTO task_comments (id, task_id, author_type, author_id, content, type, metadata, requests_input, acp_session_id, agent_session_id, created_at) @@ -73,4 +81,3 @@ VALUES INSERT OR REPLACE INTO agent_sessions (id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, error_message, metadata, started_at, completed_at, updated_at) VALUES ('session-001', 'task-005', 'agent-instance-123', 'augment-agent', 'acp-session-abc', 'running', 0.0, '', '{}', datetime('now', '-1 hour'), NULL, datetime('now')); - diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 5dccf74a57..005981b3ab 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -56,6 +56,11 @@ const ( ExecutorDeleted = "executor.deleted" ) +// Event types for users +const ( + UserSettingsUpdated = "user.settings.updated" +) + // Event types for environments const ( EnvironmentCreated = "environment.created" diff --git a/apps/backend/internal/gateway/websocket/client.go b/apps/backend/internal/gateway/websocket/client.go index ceb667ecda..818618df47 100644 --- a/apps/backend/internal/gateway/websocket/client.go +++ b/apps/backend/internal/gateway/websocket/client.go @@ -8,6 +8,7 @@ import ( "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/user/store" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -33,6 +34,7 @@ type Client struct { hub *Hub send chan []byte subscriptions map[string]bool // Task IDs this client is subscribed to + userSubscriptions map[string]bool // User IDs this client is subscribed to mu sync.RWMutex logger *logger.Logger } @@ -45,6 +47,7 @@ func NewClient(id string, conn *websocket.Conn, hub *Hub, log *logger.Logger) *C hub: hub, send: make(chan []byte, 256), subscriptions: make(map[string]bool), + userSubscriptions: make(map[string]bool), logger: log.WithFields(zap.String("client_id", id)), } } @@ -99,6 +102,12 @@ func (c *Client) handleMessage(ctx context.Context, msg *ws.Message) { case ws.ActionTaskUnsubscribe: c.handleUnsubscribe(msg) return + case ws.ActionUserSubscribe: + c.handleUserSubscribe(msg) + return + case ws.ActionUserUnsubscribe: + c.handleUserUnsubscribe(msg) + return } // Dispatch to handler @@ -147,6 +156,56 @@ func (c *Client) handleSubscribe(msg *ws.Message) { c.sendHistoricalLogs(req.TaskID) } +type UserSubscribeRequest struct { + UserID string `json:"user_id,omitempty"` +} + +func (c *Client) handleUserSubscribe(msg *ws.Message) { + var req UserSubscribeRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return + } + + userID := req.UserID + if userID == "" { + userID = store.DefaultUserID + } + if userID != store.DefaultUserID { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "cannot subscribe to another user", nil) + return + } + + c.hub.SubscribeToUser(c, userID) + resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "user_id": userID, + }) + c.sendMessage(resp) +} + +func (c *Client) handleUserUnsubscribe(msg *ws.Message) { + var req UserSubscribeRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return + } + userID := req.UserID + if userID == "" { + userID = store.DefaultUserID + } + if userID != store.DefaultUserID { + c.sendError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "cannot unsubscribe from another user", nil) + return + } + c.hub.UnsubscribeFromUser(c, userID) + resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + "user_id": userID, + }) + c.sendMessage(resp) +} + // sendHistoricalLogs sends historical execution logs to the client func (c *Client) sendHistoricalLogs(taskID string) { ctx := context.Background() @@ -261,4 +320,4 @@ func (c *Client) WritePump() { } } } -} \ No newline at end of file +} diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index 3074fe4cf0..0637142166 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -21,6 +21,8 @@ type Hub struct { // Clients subscribed to specific tasks (for ACP notifications) taskSubscribers map[string]map[*Client]bool + // Clients subscribed to specific users (for user settings notifications) + userSubscribers map[string]map[*Client]bool // Channels for client management register chan *Client @@ -44,6 +46,7 @@ func NewHub(dispatcher *ws.Dispatcher, log *logger.Logger) *Hub { return &Hub{ clients: make(map[*Client]bool), taskSubscribers: make(map[string]map[*Client]bool), + userSubscribers: make(map[string]map[*Client]bool), register: make(chan *Client), unregister: make(chan *Client), broadcast: make(chan *ws.Message, 256), @@ -108,6 +111,14 @@ func (h *Hub) removeClient(client *Client) { } } } + for userID := range client.userSubscriptions { + if clients, ok := h.userSubscribers[userID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.userSubscribers, userID) + } + } + } } h.logger.Debug("Client unregistered", zap.String("client_id", client.ID)) } @@ -180,6 +191,37 @@ func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { } } +// BroadcastToUser sends a notification to clients subscribed to a specific user +func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) { + data, err := json.Marshal(msg) + if err != nil { + h.logger.Error("Failed to marshal message", zap.Error(err)) + return + } + + h.mu.RLock() + clients := h.userSubscribers[userID] + numClients := len(clients) + h.mu.RUnlock() + + h.logger.Debug("BroadcastToUser", + zap.String("user_id", userID), + zap.String("action", msg.Action), + zap.Int("subscriber_count", numClients)) + + for client := range clients { + select { + case client.send <- data: + h.logger.Debug("Sent message to client", + zap.String("client_id", client.ID), + zap.String("action", msg.Action)) + default: + h.logger.Warn("Client buffer full, dropping message", + zap.String("client_id", client.ID)) + } + } +} + // SubscribeToTask subscribes a client to task notifications func (h *Hub) SubscribeToTask(client *Client, taskID string) { h.mu.Lock() @@ -196,6 +238,36 @@ func (h *Hub) SubscribeToTask(client *Client, taskID string) { zap.String("task_id", taskID)) } +// SubscribeToUser subscribes a client to user notifications +func (h *Hub) SubscribeToUser(client *Client, userID string) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.userSubscribers[userID]; !ok { + h.userSubscribers[userID] = make(map[*Client]bool) + } + h.userSubscribers[userID][client] = true + client.userSubscriptions[userID] = true + + h.logger.Debug("Client subscribed to user", + zap.String("client_id", client.ID), + zap.String("user_id", userID)) +} + +// UnsubscribeFromUser unsubscribes a client from user notifications +func (h *Hub) UnsubscribeFromUser(client *Client, userID string) { + h.mu.Lock() + defer h.mu.Unlock() + + delete(client.userSubscriptions, userID) + if clients, ok := h.userSubscribers[userID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.userSubscribers, userID) + } + } +} + // UnsubscribeFromTask unsubscribes a client from task notifications func (h *Hub) UnsubscribeFromTask(client *Client, taskID string) { h.mu.Lock() @@ -233,4 +305,4 @@ func (h *Hub) GetHistoricalLogs(ctx context.Context, taskID string) ([]*ws.Messa return nil, nil } return h.historicalLogsProvider(ctx, taskID) -} \ No newline at end of file +} diff --git a/apps/backend/internal/gateway/websocket/user_notifications.go b/apps/backend/internal/gateway/websocket/user_notifications.go new file mode 100644 index 0000000000..e833b41ac2 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/user_notifications.go @@ -0,0 +1,67 @@ +package websocket + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type UserEventBroadcaster struct { + hub *Hub + subscriptions []bus.Subscription + logger *logger.Logger +} + +func RegisterUserNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *UserEventBroadcaster { + b := &UserEventBroadcaster{ + hub: hub, + logger: log.WithFields(zap.String("component", "ws-user-broadcaster")), + } + if eventBus == nil { + return b + } + + b.subscribe(eventBus, events.UserSettingsUpdated, ws.ActionUserSettingsUpdated) + + go func() { + <-ctx.Done() + b.Close() + }() + + return b +} + +func (b *UserEventBroadcaster) Close() { + for _, sub := range b.subscriptions { + if sub != nil && sub.IsValid() { + _ = sub.Unsubscribe() + } + } + b.subscriptions = nil +} + +func (b *UserEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) { + sub, err := eventBus.Subscribe(subject, func(ctx context.Context, event *bus.Event) error { + data := event.Data + userID, _ := data["user_id"].(string) + if userID == "" { + return nil + } + msg, err := ws.NewNotification(action, data) + if err != nil { + b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err)) + return nil + } + b.hub.BroadcastToUser(userID, msg) + return nil + }) + if err != nil { + b.logger.Error("failed to subscribe to events", zap.String("subject", subject), zap.Error(err)) + return + } + b.subscriptions = append(b.subscriptions, sub) +} diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 9825c438f1..efcc93a6b9 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -442,6 +442,7 @@ func TestTaskCRUD(t *testing.T) { "title": "Test Task", "description": "A test task for integration testing", "priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH) + "repository_url": "/tmp/repo", }) require.NoError(t, err) @@ -576,6 +577,7 @@ func TestTaskStateTransitions(t *testing.T) { "column_id": columnID, "title": "State Test Task", "description": "Test state transitions", + "repository_url": "/tmp/repo", }) var taskPayload map[string]interface{} taskResp.ParsePayload(&taskPayload) @@ -660,6 +662,7 @@ func TestTaskMove(t *testing.T) { "board_id": boardID, "column_id": col1ID, "title": "Movable Task", + "repository_url": "/tmp/repo", }) var taskPayload map[string]interface{} taskResp.ParsePayload(&taskPayload) @@ -770,6 +773,7 @@ func TestMultipleClients(t *testing.T) { "board_id": boardID, "column_id": columnID, "title": "Task by Client 1", + "repository_url": "/tmp/repo", }) require.NoError(t, err) @@ -890,6 +894,7 @@ func TestTaskSubscription(t *testing.T) { "board_id": boardID, "column_id": columnID, "title": "Subscribable Task", + "repository_url": "/tmp/repo", }) var taskPayload map[string]interface{} taskResp.ParsePayload(&taskPayload) @@ -980,6 +985,7 @@ func TestConcurrentRequests(t *testing.T) { "board_id": boardID, "column_id": columnID, "title": "Concurrent Task " + string(rune('0'+idx)), + "repository_url": "/tmp/repo", }, ) results <- err diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index a0743e4155..dfd96c02d9 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -590,6 +590,7 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentType string, Description: "This is a test task for the orchestrator", Priority: priority, AgentType: agentType, + RepositoryURL: "/tmp/repo", }) require.NoError(t, err) @@ -1342,6 +1343,7 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { "description": "Create a new feature with tests", "priority": 2, "agent_type": "augment-agent", + "repository_url": "/tmp/repo", }) require.NoError(t, err) diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 3b17d66655..e329313e83 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -98,6 +98,10 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) return } + if body.RepositoryURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_url is required"}) + return + } resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{ WorkspaceID: body.WorkspaceID, BoardID: body.BoardID, @@ -249,6 +253,9 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Title == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) } + if req.RepositoryURL == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_url is required", nil) + } resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{ WorkspaceID: req.WorkspaceID, diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 6eca0d803e..d6879fbd82 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "strings" "time" "github.com/google/uuid" @@ -207,6 +208,9 @@ type UpdateRepositoryScriptRequest struct { // CreateTask creates a new task and publishes a task.created event func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) { + if strings.TrimSpace(req.RepositoryURL) == "" { + return nil, fmt.Errorf("repository_url is required") + } state := v1.TaskStateCreated if req.State != nil { state = *req.State @@ -1036,6 +1040,9 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * if task.AssignedTo != "" { data["assigned_to"] = task.AssignedTo } + if task.RepositoryURL != "" { + data["repository_url"] = task.RepositoryURL + } if task.Metadata != nil { data["metadata"] = task.Metadata } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index e9deb96be3..dc60e71936 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -96,6 +96,7 @@ func TestService_CreateTask(t *testing.T) { Title: "Test Task", Description: "A test task", Priority: 5, + RepositoryURL: "/tmp/repo", } task, err := svc.CreateTask(ctx, req) diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go new file mode 100644 index 0000000000..e8a4335da8 --- /dev/null +++ b/apps/backend/internal/user/controller/controller.go @@ -0,0 +1,51 @@ +package controller + +import ( + "context" + + "github.com/kandev/kandev/internal/user/dto" + "github.com/kandev/kandev/internal/user/service" +) + +type Controller struct { + svc *service.Service +} + +func NewController(svc *service.Service) *Controller { + return &Controller{svc: svc} +} + +func (c *Controller) GetCurrentUser(ctx context.Context) (dto.UserResponse, error) { + user, err := c.svc.GetCurrentUser(ctx) + if err != nil { + return dto.UserResponse{}, err + } + settings, err := c.svc.GetUserSettings(ctx) + if err != nil { + return dto.UserResponse{}, err + } + return dto.UserResponse{ + User: dto.FromUser(user), + Settings: dto.FromUserSettings(settings), + }, nil +} + +func (c *Controller) GetUserSettings(ctx context.Context) (dto.UserSettingsResponse, error) { + settings, err := c.svc.GetUserSettings(ctx) + if err != nil { + return dto.UserSettingsResponse{}, err + } + return dto.UserSettingsResponse{Settings: dto.FromUserSettings(settings)}, nil +} + +func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) { + settings, err := c.svc.UpdateUserSettings(ctx, &service.UpdateUserSettingsRequest{ + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + RepositoryIDs: req.RepositoryIDs, + }) + if err != nil { + return dto.UserSettingsResponse{}, err + } + return dto.UserSettingsResponse{Settings: dto.FromUserSettings(settings)}, nil +} diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go new file mode 100644 index 0000000000..7db55d982b --- /dev/null +++ b/apps/backend/internal/user/dto/dto.go @@ -0,0 +1,56 @@ +package dto + +import ( + "time" + + "github.com/kandev/kandev/internal/user/models" +) + +type UserDTO struct { + ID string `json:"id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserSettingsDTO struct { + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + UpdatedAt string `json:"updated_at"` +} + +type UserResponse struct { + User UserDTO `json:"user"` + Settings UserSettingsDTO `json:"settings"` +} + +type UserSettingsResponse struct { + Settings UserSettingsDTO `json:"settings"` +} + +type UpdateUserSettingsRequest struct { + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` +} + +func FromUser(user *models.User) UserDTO { + return UserDTO{ + ID: user.ID, + Email: user.Email, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { + return UserSettingsDTO{ + UserID: settings.UserID, + WorkspaceID: settings.WorkspaceID, + BoardID: settings.BoardID, + RepositoryIDs: settings.RepositoryIDs, + UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), + } +} diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go new file mode 100644 index 0000000000..4add5b772c --- /dev/null +++ b/apps/backend/internal/user/handlers/handlers.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/user/controller" + "github.com/kandev/kandev/internal/user/dto" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type Handlers struct { + controller *controller.Controller + logger *logger.Logger +} + +func NewHandlers(ctrl *controller.Controller, log *logger.Logger) *Handlers { + return &Handlers{ + controller: ctrl, + logger: log.WithFields(zap.String("component", "user-handlers")), + } +} + +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.Controller, log *logger.Logger) { + h := NewHandlers(ctrl, log) + h.registerHTTP(router) + h.registerWS(dispatcher) +} + +func (h *Handlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/user", h.httpGetUser) + api.GET("/user/settings", h.httpGetUserSettings) + api.PATCH("/user/settings", h.httpUpdateUserSettings) +} + +func (h *Handlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionUserGet, h.wsGetUser) + dispatcher.RegisterFunc(ws.ActionUserSettingsUpdate, h.wsUpdateUserSettings) +} + +func (h *Handlers) httpGetUser(c *gin.Context) { + resp, err := h.controller.GetCurrentUser(c.Request.Context()) + if err != nil { + h.logger.Error("failed to get user", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpGetUserSettings(c *gin.Context) { + resp, err := h.controller.GetUserSettings(c.Request.Context()) + if err != nil { + h.logger.Error("failed to get user settings", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user settings"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpUpdateUserSettingsRequest struct { + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` +} + +func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { + var body httpUpdateUserSettingsRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.UpdateUserSettings(c.Request.Context(), dto.UpdateUserSettingsRequest{ + WorkspaceID: body.WorkspaceID, + BoardID: body.BoardID, + RepositoryIDs: body.RepositoryIDs, + }) + if err != nil { + h.logger.Error("failed to update user settings", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user settings"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) wsGetUser(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + resp, err := h.controller.GetCurrentUser(ctx) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get user", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsUpdateUserSettingsRequest struct { + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` +} + +func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateUserSettingsRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + resp, err := h.controller.UpdateUserSettings(ctx, dto.UpdateUserSettingsRequest{ + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + RepositoryIDs: req.RepositoryIDs, + }) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go new file mode 100644 index 0000000000..241f1a643f --- /dev/null +++ b/apps/backend/internal/user/models/models.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserSettings struct { + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go new file mode 100644 index 0000000000..4283d24f61 --- /dev/null +++ b/apps/backend/internal/user/service/service.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + "errors" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/user/models" + "github.com/kandev/kandev/internal/user/store" + "go.uber.org/zap" +) + +var ErrUserNotFound = errors.New("user not found") + +type Service struct { + repo store.Repository + eventBus bus.EventBus + logger *logger.Logger + defaultUser string +} + +type UpdateUserSettingsRequest struct { + WorkspaceID *string + BoardID *string + RepositoryIDs *[]string +} + +func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { + return &Service{ + repo: repo, + eventBus: eventBus, + logger: log.WithFields(zap.String("component", "user-service")), + defaultUser: store.DefaultUserID, + } +} + +func (s *Service) GetCurrentUser(ctx context.Context) (*models.User, error) { + user, err := s.repo.GetUser(ctx, s.defaultUser) + if err != nil { + return nil, ErrUserNotFound + } + return user, nil +} + +func (s *Service) GetUserSettings(ctx context.Context) (*models.UserSettings, error) { + settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + if err != nil { + return nil, err + } + return settings, nil +} + +func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) { + settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + if err != nil { + return nil, err + } + if req.WorkspaceID != nil { + settings.WorkspaceID = *req.WorkspaceID + } + if req.BoardID != nil { + settings.BoardID = *req.BoardID + } + if req.RepositoryIDs != nil { + settings.RepositoryIDs = *req.RepositoryIDs + } + settings.UpdatedAt = time.Now().UTC() + if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { + return nil, err + } + s.publishUserSettingsEvent(ctx, settings) + return settings, nil +} + +func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models.UserSettings) { + if s.eventBus == nil || settings == nil { + return + } + data := map[string]interface{}{ + "user_id": settings.UserID, + "workspace_id": settings.WorkspaceID, + "board_id": settings.BoardID, + "repository_ids": settings.RepositoryIDs, + "updated_at": settings.UpdatedAt.Format(time.RFC3339), + } + if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { + s.logger.Error("failed to publish user settings event", zap.Error(err)) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go new file mode 100644 index 0000000000..be7114d677 --- /dev/null +++ b/apps/backend/internal/user/store/sqlite.go @@ -0,0 +1,238 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/user/models" +) + +const ( + DefaultUserID = "default-user" + DefaultUserEmail = "default@kandev.local" +) + +type SQLiteRepository struct { + db *sql.DB +} + +var _ Repository = (*SQLiteRepository)(nil) + +func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { + normalizedPath := normalizeSQLitePath(dbPath) + if err := ensureSQLiteDir(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to prepare database path: %w", err) + } + if err := ensureSQLiteFile(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to create database file: %w", err) + } + dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + repo := &SQLiteRepository{db: db} + if err := repo.initSchema(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + return repo, nil +} + +func ensureSQLiteDir(dbPath string) error { + dir := filepath.Dir(dbPath) + if dir == "." || dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func ensureSQLiteFile(dbPath string) error { + file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return err + } + return file.Close() +} + +func normalizeSQLitePath(dbPath string) string { + if dbPath == "" { + return dbPath + } + abs, err := filepath.Abs(dbPath) + if err != nil { + return dbPath + } + return abs +} + +func (r *SQLiteRepository) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + ` + if _, err := r.db.Exec(schema); err != nil { + return err + } + if err := r.ensureUserSettingsColumn(); err != nil { + return err + } + + return r.ensureDefaultUser() +} + +func (r *SQLiteRepository) ensureUserSettingsColumn() error { + exists, err := r.columnExists("users", "settings") + if err != nil { + return err + } + if exists { + return nil + } + _, err = r.db.Exec(`ALTER TABLE users ADD COLUMN settings TEXT NOT NULL DEFAULT '{}'`) + return err +} + +func (r *SQLiteRepository) columnExists(table, column string) (bool, error) { + rows, err := r.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var cid int + var name, colType string + var notNull int + var defaultValue *string + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() +} + +func (r *SQLiteRepository) ensureDefaultUser() error { + ctx := context.Background() + var count int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM users WHERE id = ?", DefaultUserID).Scan(&count); err != nil { + return err + } + if count == 0 { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, ` + INSERT INTO users (id, email, settings, created_at, updated_at) + VALUES (?, ?, '{}', ?, ?) + `, DefaultUserID, DefaultUserEmail, now, now) + if err != nil { + return err + } + } + return nil +} + +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +func (r *SQLiteRepository) GetUser(ctx context.Context, id string) (*models.User, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, email, created_at, updated_at + FROM users WHERE id = ? + `, id) + return scanUser(row) +} + +func (r *SQLiteRepository) GetDefaultUser(ctx context.Context) (*models.User, error) { + return r.GetUser(ctx, DefaultUserID) +} + +func (r *SQLiteRepository) GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT settings, updated_at + FROM users WHERE id = ? + `, userID) + settings, err := scanUserSettings(row, userID) + if err != nil { + return nil, err + } + return settings, nil +} + +func (r *SQLiteRepository) UpsertUserSettings(ctx context.Context, settings *models.UserSettings) error { + settings.UpdatedAt = time.Now().UTC() + if settings.CreatedAt.IsZero() { + settings.CreatedAt = settings.UpdatedAt + } + settingsPayload, err := json.Marshal(map[string]interface{}{ + "workspace_id": settings.WorkspaceID, + "board_id": settings.BoardID, + "repository_ids": settings.RepositoryIDs, + }) + if err != nil { + return err + } + result, err := r.db.ExecContext(ctx, ` + UPDATE users + SET settings = ?, updated_at = ? + WHERE id = ? + `, string(settingsPayload), settings.UpdatedAt, settings.UserID) + if err == nil { + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("user not found: %s", settings.UserID) + } + } + return err +} + +func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) { + user := &models.User{} + if err := scanner.Scan(&user.ID, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil { + return nil, err + } + return user, nil +} + +func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID string) (*models.UserSettings, error) { + settings := &models.UserSettings{} + var settingsRaw string + if err := scanner.Scan(&settingsRaw, &settings.UpdatedAt); err != nil { + return nil, err + } + settings.UserID = userID + if settingsRaw == "" || settingsRaw == "{}" { + settings.RepositoryIDs = []string{} + return settings, nil + } + var payload struct { + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + } + if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { + return nil, err + } + settings.WorkspaceID = payload.WorkspaceID + settings.BoardID = payload.BoardID + settings.RepositoryIDs = payload.RepositoryIDs + return settings, nil +} diff --git a/apps/backend/internal/user/store/store.go b/apps/backend/internal/user/store/store.go new file mode 100644 index 0000000000..cbbfbd628b --- /dev/null +++ b/apps/backend/internal/user/store/store.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + + "github.com/kandev/kandev/internal/user/models" +) + +type Repository interface { + GetUser(ctx context.Context, id string) (*models.User, error) + GetDefaultUser(ctx context.Context) (*models.User, error) + GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error) + UpsertUserSettings(ctx context.Context, settings *models.UserSettings) error + Close() error +} diff --git a/apps/backend/pkg/api/v1/user.go b/apps/backend/pkg/api/v1/user.go index 5d18f682b8..90559f2f27 100644 --- a/apps/backend/pkg/api/v1/user.go +++ b/apps/backend/pkg/api/v1/user.go @@ -6,7 +6,14 @@ import "time" type User struct { ID string `json:"id"` Email string `json:"email"` - Username string `json:"username"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +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"` +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index e07d3c0f17..7d7361f2e0 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -89,6 +89,8 @@ const ( // Subscription actions ActionTaskSubscribe = "task.subscribe" ActionTaskUnsubscribe = "task.unsubscribe" + ActionUserSubscribe = "user.subscribe" + ActionUserUnsubscribe = "user.unsubscribe" // Comment actions ActionCommentAdd = "comment.add" @@ -138,6 +140,11 @@ const ( // Permission request actions (agent -> user -> agent) ActionPermissionRequested = "permission.requested" // Agent requesting permission ActionPermissionRespond = "permission.respond" // User responding to permission request + + // User actions + ActionUserGet = "user.get" + ActionUserSettingsUpdate = "user.settings.update" + ActionUserSettingsUpdated = "user.settings.updated" ) // Error codes diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 551cf2b8c4..416ace5add 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,6 @@ import { PageClient } from '@/app/page-client'; import { StateHydrator } from '@/components/state-hydrator'; -import { fetchBoardSnapshot, fetchBoards, fetchWorkspaces } from '@/lib/ssr/http'; +import { fetchBoardSnapshot, fetchBoards, fetchUserSettings, fetchWorkspaces } from '@/lib/ssr/http'; import { snapshotToState } from '@/lib/ssr/mapper'; import type { AppState } from '@/lib/state/store'; @@ -17,9 +17,17 @@ export default async function Page({ searchParams }: PageProps) { const workspaceId = Array.isArray(workspaceParam) ? workspaceParam[0] : workspaceParam; const boardIdParam = Array.isArray(boardParam) ? boardParam[0] : boardParam; - const workspaces = await fetchWorkspaces(); + const [workspaces, userSettingsResponse] = await Promise.all([ + fetchWorkspaces(), + fetchUserSettings().catch(() => null), + ]); + const userSettings = userSettingsResponse?.settings; + const settingsWorkspaceId = userSettings?.workspace_id || null; + const settingsBoardId = userSettings?.board_id || null; + const settingsRepositoryIds = Array.from(new Set(userSettings?.repository_ids ?? [])).sort(); const activeWorkspaceId = workspaces.workspaces.find((workspace) => workspace.id === workspaceId)?.id ?? + workspaces.workspaces.find((workspace) => workspace.id === settingsWorkspaceId)?.id ?? workspaces.workspaces[0]?.id ?? null; let initialState: Partial = { @@ -30,6 +38,12 @@ export default async function Page({ searchParams }: PageProps) { })), activeId: activeWorkspaceId, }, + userSettings: { + workspaceId: settingsWorkspaceId, + boardId: settingsBoardId, + repositoryIds: settingsRepositoryIds, + loaded: Boolean(userSettings), + }, }; if (!activeWorkspaceId) { @@ -43,7 +57,9 @@ export default async function Page({ searchParams }: PageProps) { const boardList = await fetchBoards(activeWorkspaceId); const boardId = - boardList.boards.find((board) => board.id === boardIdParam)?.id ?? boardList.boards[0]?.id; + boardList.boards.find((board) => board.id === boardIdParam)?.id ?? + boardList.boards.find((board) => board.id === settingsBoardId)?.id ?? + boardList.boards[0]?.id; initialState = { ...initialState, boards: { diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx new file mode 100644 index 0000000000..7883236b42 --- /dev/null +++ b/apps/web/components/kanban-board-grid.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { KanbanColumn, Column } from './kanban-column'; +import { KanbanCardPreview, Task } from './kanban-card'; + +export type KanbanBoardGridProps = { + columns: Column[]; + tasks: Task[]; + onOpenTask: (task: Task) => void; + onEditTask: (task: Task) => void; + onDeleteTask: (task: Task) => void; + onDragStart: (event: DragStartEvent) => void; + onDragEnd: (event: DragEndEvent) => void; + onDragCancel: () => void; + activeTask: Task | null; +}; + +export function KanbanBoardGrid({ + columns, + tasks, + onOpenTask, + onEditTask, + onDeleteTask, + onDragStart, + onDragEnd, + onDragCancel, + activeTask, +}: KanbanBoardGridProps) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const getTasksForColumn = (columnId: string) => { + return tasks + .filter((task) => task.columnId === columnId) + .map((task) => ({ ...task, position: task.position ?? 0 })) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + }; + + return ( + +
+ {columns.length === 0 ? ( +
+ No boards available yet. +
+ ) : ( +
+ {columns.map((column) => ( + + ))} +
+ )} +
+ + {activeTask ? : null} + +
+ ); +} diff --git a/apps/web/components/kanban-board-header.tsx b/apps/web/components/kanban-board-header.tsx new file mode 100644 index 0000000000..fa92cad618 --- /dev/null +++ b/apps/web/components/kanban-board-header.tsx @@ -0,0 +1,154 @@ +'use client'; + +import Link from 'next/link'; +import { Button } from '@kandev/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@kandev/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kandev/ui/select'; +import { IconAdjustmentsHorizontal, IconPlus, IconSettings } from '@tabler/icons-react'; +import { ConnectionStatus } from './connection-status'; +import type { Repository } from '@/lib/types/http'; + +type KanbanBoardHeaderProps = { + workspaces: Array<{ id: string; name: string }>; + boards: Array<{ id: string; workspaceId: string; name: string }>; + activeWorkspaceId: string | null; + activeBoardId: string | null; + repositories: Repository[]; + repositoriesLoading: boolean; + allRepositoriesSelected: boolean; + selectedRepositoryId: string | null; + onWorkspaceChange: (workspaceId: string | null) => void; + onBoardChange: (boardId: string | null) => void; + onRepositoryChange: (repositoryId: string | 'all') => void; + onAddTask: () => void; +}; + +export function KanbanBoardHeader({ + workspaces, + boards, + activeWorkspaceId, + activeBoardId, + repositories, + repositoriesLoading, + allRepositoriesSelected, + selectedRepositoryId, + onWorkspaceChange, + onBoardChange, + onRepositoryChange, + onAddTask, +}: KanbanBoardHeaderProps) { + const repositoryValue = allRepositoriesSelected ? 'all' : selectedRepositoryId ?? 'all'; + + return ( +
+
+ + KanDev.ai + + +
+
+ + + + + +
+
+ Workspace + +
+ +
+ Board + +
+ +
+ Repository + +
+
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 94135f747f..7e4ff53aa5 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -1,29 +1,10 @@ 'use client'; import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { KanbanColumn, Column } from './kanban-column'; -import { KanbanCardPreview, Task } from './kanban-card'; -import { ConnectionStatus } from './connection-status'; -import { Button } from '@kandev/ui/button'; -import { IconPlus, IconSettings } from '@tabler/icons-react'; +import { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; +import { Column } from './kanban-column'; +import { Task } from './kanban-card'; import { TaskCreateDialog } from './task-create-dialog'; -import Link from 'next/link'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@kandev/ui/select'; import { useRouter } from 'next/navigation'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; @@ -32,6 +13,10 @@ import { getBackendConfig } from '@/lib/config'; import { deleteTask, fetchBoardSnapshot, moveTask } from '@/lib/http/client'; import { snapshotToState } from '@/lib/ssr/mapper'; import type { Task as BackendTask } from '@/lib/types/http'; +import { filterTasksByRepositories } from '@/lib/kanban/filters'; +import { useUserDisplaySettings } from '@/hooks/use-user-display-settings'; +import { KanbanBoardHeader } from './kanban-board-header'; +import { KanbanBoardGrid } from './kanban-board-grid'; export function KanbanBoard() { const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -46,6 +31,33 @@ export function KanbanBoard() { const setActiveBoard = useAppStore((state) => state.setActiveBoard); const setBoards = useAppStore((state) => state.setBoards); const store = useAppStoreApi(); + const { + settings: userSettings, + commitSettings, + repositories, + repositoriesLoading, + allRepositoriesSelected, + selectedRepositoryPaths, + } = useUserDisplaySettings({ + workspaceId: workspaceState.activeId, + boardId: boardsState.activeId, + onWorkspaceChange: (nextWorkspaceId) => { + setActiveWorkspace(nextWorkspaceId); + if (nextWorkspaceId) { + router.push(`/?workspaceId=${nextWorkspaceId}`); + } else { + router.push('/'); + } + }, + onBoardChange: (nextBoardId) => { + setActiveBoard(nextBoardId); + if (nextBoardId) { + const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId; + const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ''; + router.push(`/?boardId=${nextBoardId}${workspaceParam}`); + } + }, + }); const isMounted = useSyncExternalStore( () => () => { }, @@ -53,14 +65,6 @@ export function KanbanBoard() { () => false ); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - const backendColumns = useMemo( () => [...kanban.columns] @@ -81,11 +85,15 @@ export function KanbanBoard() { state: task.state, description: task.description, position: task.position, + repositoryUrl: task.repositoryUrl, })), [kanban.tasks] ); const activeColumns = kanban.boardId ? backendColumns : []; - const visibleTasks = backendTasks; + const visibleTasks = useMemo( + () => filterTasksByRepositories(backendTasks, selectedRepositoryPaths), + [backendTasks, selectedRepositoryPaths] + ); const activeTask = useMemo( () => visibleTasks.find((task) => task.id === activeTaskId) ?? null, [visibleTasks, activeTaskId] @@ -155,6 +163,7 @@ export function KanbanBoard() { description: task.description ?? undefined, position: task.position ?? 0, state: task.state, + repositoryUrl: task.repository_url ?? undefined, }, ], }, @@ -173,6 +182,7 @@ export function KanbanBoard() { columnId: task.column_id ?? item.columnId, position: task.position ?? item.position, state: task.state ?? item.state, + repositoryUrl: task.repository_url ?? item.repositoryUrl, } : item ), @@ -204,15 +214,6 @@ export function KanbanBoard() { } }; - const getTasksForColumn = (columnId: string) => { - return visibleTasks - .filter((task) => task.columnId === columnId) - .map((task) => { - const position = kanban.tasks.find((item) => item.id === task.id)?.position ?? 0; - return { ...task, position }; - }) - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); - }; useEffect(() => { const workspaceId = workspaceState.activeId; @@ -228,9 +229,19 @@ export function KanbanBoard() { name: board.name, })); setBoards(boards); - const nextBoardId = boards[0]?.id ?? null; - setActiveBoard(nextBoardId); - if (!nextBoardId) { + const desiredBoardId = + (userSettings.boardId && boards.some((board) => board.id === userSettings.boardId) + ? userSettings.boardId + : boards[0]?.id) ?? null; + setActiveBoard(desiredBoardId); + if (userSettings.loaded && desiredBoardId !== userSettings.boardId) { + commitSettings({ + workspaceId, + boardId: desiredBoardId, + repositoryIds: userSettings.repositoryIds, + }); + } + if (!desiredBoardId) { store.getState().hydrate({ kanban: { boardId: null, columns: [], tasks: [] }, }); @@ -239,7 +250,16 @@ export function KanbanBoard() { .catch(() => { // Ignore board list errors for now. }); - }, [setActiveBoard, setBoards, store, workspaceState.activeId]); + }, [ + commitSettings, + setActiveBoard, + setBoards, + store, + userSettings.boardId, + userSettings.loaded, + userSettings.repositoryIds, + workspaceState.activeId, + ]); useEffect(() => { if (!boardsState.activeId) return; @@ -254,80 +274,68 @@ export function KanbanBoard() { }, [boardsState.activeId, store]); + if (!isMounted) { return
; } return (
-
-
- - KanDev.ai - - - -
-
- - - - - -
-
+ { + setActiveWorkspace(nextWorkspaceId); + if (nextWorkspaceId) { + router.push(`/?workspaceId=${nextWorkspaceId}`); + } else { + router.push('/'); + } + commitSettings({ + workspaceId: nextWorkspaceId, + boardId: null, + repositoryIds: [], + }); + }} + onBoardChange={(nextBoardId) => { + setActiveBoard(nextBoardId); + if (nextBoardId) { + const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId; + const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ''; + router.push(`/?boardId=${nextBoardId}${workspaceParam}`); + } + commitSettings({ + workspaceId: userSettings.workspaceId, + boardId: nextBoardId, + repositoryIds: userSettings.repositoryIds, + }); + }} + onRepositoryChange={(value) => { + if (value === 'all') { + commitSettings({ + workspaceId: userSettings.workspaceId, + boardId: userSettings.boardId, + repositoryIds: [], + }); + return; + } + commitSettings({ + workspaceId: userSettings.workspaceId, + boardId: userSettings.boardId, + repositoryIds: [value], + }); + }} + onAddTask={() => { + setEditingTask(null); + setIsDialogOpen(true); + }} + /> - -
- {activeColumns.length === 0 ? ( -
- No boards available yet. -
- ) : ( -
- {activeColumns.map((column) => ( - - ))} -
- )} -
- - {activeTask ? : null} - -
+ activeTask={activeTask} + />
); } diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index b202892a65..9fe9455af5 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -19,6 +19,7 @@ export interface Task { state?: string; description?: string; position?: number; + repositoryUrl?: string; } interface KanbanCardProps { diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 8df11dc1a8..988d4ac77b 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -244,6 +244,7 @@ export function TaskCreateDialog({ return; } if (!workspaceId || !boardId) return; + if (!repositoryId) return; const columnId = editingTask?.columnId ?? defaultColumnId; if (!columnId) return; let targetColumnId = columnId; @@ -493,7 +494,9 @@ export function TaskCreateDialog({ Cancel - + diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts new file mode 100644 index 0000000000..765354b255 --- /dev/null +++ b/apps/web/hooks/use-user-display-settings.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { getBackendConfig } from '@/lib/config'; +import { listRepositories } from '@/lib/http/client'; +import type { Repository } from '@/lib/types/http'; +import { mapSelectedRepositoryPaths } from '@/lib/kanban/filters'; + +const DEFAULT_SETTINGS = { + workspaceId: null as string | null, + boardId: null as string | null, + repositoryIds: [] as string[], + loaded: false, +}; + +type DisplaySettings = typeof DEFAULT_SETTINGS; + +type UseUserDisplaySettingsInput = { + workspaceId: string | null; + boardId: string | null; + onWorkspaceChange?: (workspaceId: string | null) => void; + onBoardChange?: (boardId: string | null) => void; +}; + +type CommitPayload = { + workspaceId: string | null; + boardId: string | null; + repositoryIds: string[]; +}; + +export function useUserDisplaySettings({ + workspaceId, + boardId, + onWorkspaceChange, + onBoardChange, +}: UseUserDisplaySettingsInput) { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [repositories, setRepositories] = useState([]); + const [repositoriesLoading, setRepositoriesLoading] = useState(false); + + const commitSettings = useCallback( + (next: CommitPayload) => { + const repositoryIds = Array.from(new Set(next.repositoryIds)).sort(); + const normalized = { ...next, repositoryIds, loaded: true }; + const sameWorkspace = normalized.workspaceId === settings.workspaceId; + const sameBoard = normalized.boardId === settings.boardId; + const sameRepos = + normalized.repositoryIds.length === settings.repositoryIds.length && + normalized.repositoryIds.every((id, index) => id === settings.repositoryIds[index]); + if (sameWorkspace && sameBoard && sameRepos && settings.loaded) { + return; + } + setSettings({ ...normalized, loaded: true }); + const payload = { + workspace_id: normalized.workspaceId ?? '', + board_id: normalized.boardId ?? '', + repository_ids: normalized.repositoryIds, + }; + const client = getWebSocketClient(); + if (!client) { + fetch(`${getBackendConfig().apiBaseUrl}/api/v1/user/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => { + // Ignore update errors for now; local state stays responsive. + }); + return; + } + client.request('user.settings.update', payload).catch(() => { + // Ignore update errors for now; local state stays responsive. + }); + }, + [settings.boardId, settings.loaded, settings.repositoryIds, settings.workspaceId] + ); + + useEffect(() => { + if (settings.loaded) return; + fetch(`${getBackendConfig().apiBaseUrl}/api/v1/user/settings`, { cache: 'no-store' }) + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + if (!data?.settings) return; + const repositoryIds = Array.from(new Set(data.settings.repository_ids ?? [])).sort(); + setSettings({ + workspaceId: data.settings.workspace_id || null, + boardId: data.settings.board_id || null, + repositoryIds, + loaded: true, + }); + }) + .catch(() => { + // Ignore settings fetch errors for now. + }); + }, [settings.loaded]); + + useEffect(() => { + if (!settings.loaded) return; + if (settings.workspaceId && settings.workspaceId !== workspaceId) { + onWorkspaceChange?.(settings.workspaceId); + } + }, [onWorkspaceChange, settings.loaded, settings.workspaceId, workspaceId]); + + useEffect(() => { + if (!settings.loaded) return; + if (!settings.workspaceId && workspaceId) { + queueMicrotask(() => { + commitSettings({ + workspaceId, + boardId: settings.boardId, + repositoryIds: settings.repositoryIds, + }); + }); + } + }, [commitSettings, settings.boardId, settings.loaded, settings.repositoryIds, settings.workspaceId, workspaceId]); + + useEffect(() => { + if (!settings.loaded) return; + if (settings.boardId && settings.boardId !== boardId) { + onBoardChange?.(settings.boardId); + } + }, [boardId, onBoardChange, settings.boardId, settings.loaded]); + + useEffect(() => { + if (!workspaceId) { + queueMicrotask(() => { + setRepositories([]); + }); + return; + } + queueMicrotask(() => { + setRepositoriesLoading(true); + }); + listRepositories(getBackendConfig().apiBaseUrl, workspaceId) + .then((response) => { + setRepositories(response.repositories); + }) + .catch(() => { + setRepositories([]); + }) + .finally(() => { + setRepositoriesLoading(false); + }); + }, [workspaceId]); + + useEffect(() => { + if (!settings.loaded) return; + if (repositories.length === 0) return; + const repoIds = repositories.map((repo) => repo.id); + const validIds = settings.repositoryIds.filter((id) => repoIds.includes(id)); + const nextIds = validIds; + const isSame = + nextIds.length === settings.repositoryIds.length && + nextIds.every((id, index) => id === settings.repositoryIds[index]); + if (!isSame) { + queueMicrotask(() => { + commitSettings({ + workspaceId: settings.workspaceId, + boardId: settings.boardId, + repositoryIds: nextIds, + }); + }); + } + }, [commitSettings, repositories, settings.boardId, settings.loaded, settings.repositoryIds, settings.workspaceId]); + + const allRepositoriesSelected = settings.repositoryIds.length === 0; + const selectedRepositoryPaths = useMemo( + () => mapSelectedRepositoryPaths(repositories, settings.repositoryIds), + [repositories, settings.repositoryIds] + ); + + return { + settings, + commitSettings, + repositories, + repositoriesLoading, + allRepositoriesSelected, + selectedRepositoryPaths, + }; +} diff --git a/apps/web/lib/kanban/filters.ts b/apps/web/lib/kanban/filters.ts new file mode 100644 index 0000000000..184be207c2 --- /dev/null +++ b/apps/web/lib/kanban/filters.ts @@ -0,0 +1,32 @@ +import type { Repository } from '@/lib/types/http'; +import type { KanbanState } from '@/lib/state/store'; + +export type KanbanTask = KanbanState['tasks'][number]; + +export function mapSelectedRepositoryPaths( + repositories: Repository[], + selectedIds: string[] +): Set { + if (selectedIds.length === 0) { + return new Set(); + } + const repoById = new Map(repositories.map((repo) => [repo.id, repo])); + const paths = new Set(); + selectedIds.forEach((id) => { + const repo = repoById.get(id); + if (repo?.local_path) { + paths.add(repo.local_path); + } + }); + return paths; +} + +export function filterTasksByRepositories( + tasks: KanbanTask[], + selectedRepositoryPaths: Set +): KanbanTask[] { + if (selectedRepositoryPaths.size === 0) { + return tasks; + } + return tasks.filter((task) => task.repositoryUrl && selectedRepositoryPaths.has(task.repositoryUrl)); +} diff --git a/apps/web/lib/ssr/http.ts b/apps/web/lib/ssr/http.ts index 52ce55159e..3010153541 100644 --- a/apps/web/lib/ssr/http.ts +++ b/apps/web/lib/ssr/http.ts @@ -1,4 +1,10 @@ -import type { BoardSnapshot, ListBoardsResponse, ListWorkspacesResponse, Task } from '@/lib/types/http'; +import type { + BoardSnapshot, + ListBoardsResponse, + ListWorkspacesResponse, + Task, + UserSettingsResponse, +} from '@/lib/types/http'; import { getBackendConfig } from '@/lib/config'; const { apiBaseUrl: API_BASE_URL } = getBackendConfig(); @@ -30,3 +36,7 @@ export async function fetchBoardSnapshot(boardId: string): Promise { return fetchJson(`${API_BASE_URL}/api/v1/tasks/${taskId}`); } + +export async function fetchUserSettings(): Promise { + return fetchJson(`${API_BASE_URL}/api/v1/user/settings`); +} diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index fc62ea13d8..cc839de28e 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -15,6 +15,7 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial { description: task.description ?? undefined, position: task.position ?? 0, state: task.state, + repositoryUrl: task.repository_url ?? undefined, } as KanbanTask; }) .filter((task): task is KanbanTask => task !== null); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index d3941b8ebe..115c635cfa 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -13,6 +13,7 @@ export type KanbanState = { description?: string; position: number; state?: TaskStatus; + repositoryUrl?: string; }>; }; @@ -38,6 +39,13 @@ export type AgentProfilesState = { version: number; }; +export type UserSettingsState = { + workspaceId: string | null; + boardId: string | null; + repositoryIds: string[]; + loaded: boolean; +}; + export type TerminalState = { terminals: Array<{ id: string; output: string[] }>; }; @@ -88,6 +96,7 @@ export type AppState = { tasks: TaskState; agents: AgentState; agentProfiles: AgentProfilesState; + userSettings: UserSettingsState; terminal: TerminalState; diffs: DiffState; gitStatus: GitStatusState; @@ -98,6 +107,7 @@ export type AppState = { setWorkspaces: (workspaces: WorkspaceState['items']) => void; setActiveBoard: (boardId: string | null) => void; setBoards: (boards: BoardState['items']) => void; + setUserSettings: (settings: UserSettingsState) => void; setTerminalOutput: (terminalId: string, data: string) => void; setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void; setComments: (taskId: string, comments: Comment[]) => void; @@ -118,6 +128,7 @@ const defaultState: AppState = { tasks: { activeTaskId: null }, agents: { agents: [] }, agentProfiles: { version: 0 }, + userSettings: { workspaceId: null, boardId: null, repositoryIds: [], loaded: false }, terminal: { terminals: [] }, diffs: { files: [] }, gitStatus: { @@ -141,6 +152,7 @@ const defaultState: AppState = { setWorkspaces: () => undefined, setActiveBoard: () => undefined, setBoards: () => undefined, + setUserSettings: () => undefined, setTerminalOutput: () => undefined, setConnectionStatus: () => undefined, setComments: () => undefined, @@ -161,6 +173,7 @@ function mergeInitialState( | 'setWorkspaces' | 'setActiveBoard' | 'setBoards' + | 'setUserSettings' | 'setTerminalOutput' | 'setConnectionStatus' | 'setComments' @@ -179,6 +192,7 @@ function mergeInitialState( tasks: { ...defaultState.tasks, ...initialState.tasks }, agents: { ...defaultState.agents, ...initialState.agents }, agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles }, + userSettings: { ...defaultState.userSettings, ...initialState.userSettings }, terminal: { ...defaultState.terminal, ...initialState.terminal }, diffs: { ...defaultState.diffs, ...initialState.diffs }, gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus }, @@ -201,6 +215,7 @@ export function createAppStore(initialState?: Partial) { if (state.tasks) Object.assign(draft.tasks, state.tasks); if (state.agents) Object.assign(draft.agents, state.agents); if (state.agentProfiles) Object.assign(draft.agentProfiles, state.agentProfiles); + if (state.userSettings) Object.assign(draft.userSettings, state.userSettings); if (state.terminal) Object.assign(draft.terminal, state.terminal); if (state.diffs) Object.assign(draft.diffs, state.diffs); if (state.connection) Object.assign(draft.connection, state.connection); @@ -228,6 +243,10 @@ export function createAppStore(initialState?: Partial) { set((draft) => { draft.boards.activeId = boardId; }), + setUserSettings: (settings) => + set((draft) => { + draft.userSettings = settings; + }), setTerminalOutput: (terminalId, data) => set((draft) => { const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId); diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index f3a6b7128c..d967fcf339 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -27,7 +27,8 @@ export type BackendMessageType = | 'environment.deleted' | 'agent.profile.deleted' | 'agent.profile.created' - | 'agent.profile.updated'; + | 'agent.profile.updated' + | 'user.settings.updated'; export type BackendMessage = { id?: string; @@ -61,6 +62,7 @@ export type TaskEventPayload = { state?: TaskState; priority?: number; position?: number; + repository_url?: string; }; export type AgentUpdatePayload = { @@ -187,6 +189,14 @@ export type AgentProfileChangedPayload = { agent_id: string; }; +export type UserSettingsUpdatedPayload = { + user_id: string; + workspace_id: string; + board_id: string; + repository_ids: string[]; + updated_at?: string; +}; + export type BackendMessageMap = { 'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>; 'task.created': BackendMessage<'task.created', TaskEventPayload>; @@ -217,4 +227,5 @@ export type BackendMessageMap = { 'agent.profile.deleted': BackendMessage<'agent.profile.deleted', AgentProfileDeletedPayload>; 'agent.profile.created': BackendMessage<'agent.profile.created', AgentProfileChangedPayload>; 'agent.profile.updated': BackendMessage<'agent.profile.updated', AgentProfileChangedPayload>; + 'user.settings.updated': BackendMessage<'user.settings.updated', UserSettingsUpdatedPayload>; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 33fa1e82e9..61ae24ce81 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -89,6 +89,30 @@ export type Task = { worktree_branch?: string | null; }; +export type User = { + id: string; + email: string; + created_at: string; + updated_at: string; +}; + +export type UserSettings = { + user_id: string; + workspace_id: string; + board_id: string; + repository_ids: string[]; + updated_at: string; +}; + +export type UserSettingsResponse = { + settings: UserSettings; +}; + +export type UserResponse = { + user: User; + settings: UserSettings; +}; + export type BoardSnapshot = { board: Board; columns: Column[]; diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index f986a303dc..2514e47d7d 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -43,6 +43,7 @@ export class WebSocketClient { private reconnectTimer: ReturnType | null = null; private intentionalClose = false; private subscriptions = new Set(); + private userSubscriptionActive = false; private heartbeatTimer: ReturnType | null = null; private heartbeatTimeoutTimer: ReturnType | null = null; private lastMessageTime: number = Date.now(); @@ -188,6 +189,18 @@ export class WebSocketClient { } } + subscribeUser() { + this.userSubscriptionActive = true; + if (this.status === 'open') { + this.send({ + id: generateUUID(), + type: 'request', + action: 'user.subscribe', + payload: {}, + }); + } + } + unsubscribe(taskId: string) { this.subscriptions.delete(taskId); if (this.status === 'open') { @@ -200,6 +213,18 @@ export class WebSocketClient { } } + unsubscribeUser() { + this.userSubscriptionActive = false; + if (this.status === 'open') { + this.send({ + id: generateUUID(), + type: 'request', + action: 'user.unsubscribe', + payload: {}, + }); + } + } + on(type: T, handler: MessageHandler) { const handlers = this.handlers.get(type) ?? new Set(); handlers.add(handler as MessageHandler); @@ -282,6 +307,14 @@ export class WebSocketClient { payload: { task_id: taskId }, }); }); + if (this.userSubscriptionActive) { + this.send({ + id: generateUUID(), + type: 'request', + action: 'user.subscribe', + payload: {}, + }); + } } private flushQueue() { diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts index 3433a3abf1..f2d7e740eb 100644 --- a/apps/web/lib/ws/router.ts +++ b/apps/web/lib/ws/router.ts @@ -39,6 +39,7 @@ export function registerWsHandlers(store: StoreApi) { description: message.payload.description, position: message.payload.position ?? 0, state: message.payload.state, + repositoryUrl: message.payload.repository_url, }; return { ...state, @@ -63,6 +64,7 @@ export function registerWsHandlers(store: StoreApi) { description: message.payload.description, position: message.payload.position ?? 0, state: message.payload.state, + repositoryUrl: message.payload.repository_url, }; return { ...state, @@ -106,6 +108,7 @@ export function registerWsHandlers(store: StoreApi) { description: message.payload.description, position: message.payload.position ?? 0, state: message.payload.state, + repositoryUrl: message.payload.repository_url, }; console.log('[WS Router] Next task:', nextTask); return { @@ -387,5 +390,17 @@ export function registerWsHandlers(store: StoreApi) { 'agent.profile.updated': () => { store.getState().bumpAgentProfilesVersion(); }, + 'user.settings.updated': (message: BackendMessageMap['user.settings.updated']) => { + const repositoryIds = Array.from(new Set(message.payload.repository_ids ?? [])).sort(); + store.setState((state) => ({ + ...state, + userSettings: { + workspaceId: message.payload.workspace_id || null, + boardId: message.payload.board_id || null, + repositoryIds, + loaded: true, + }, + })); + }, }; } diff --git a/apps/web/lib/ws/use-websocket.tsx b/apps/web/lib/ws/use-websocket.tsx index bed989d706..fb257d7338 100644 --- a/apps/web/lib/ws/use-websocket.tsx +++ b/apps/web/lib/ws/use-websocket.tsx @@ -21,6 +21,7 @@ export function useWebSocket(store: StoreApi, url: string) { break; case 'open': setConnectionStatus('connected', null); + client.subscribeUser(); break; case 'reconnecting': setConnectionStatus('reconnecting', null);