feat: web app support for boards and columns

This commit is contained in:
Carlos Florêncio
2026-01-11 18:30:02 +00:00
parent af6e482c4c
commit 47a26fcc0e
36 changed files with 2358 additions and 838 deletions
+1
View File
@@ -219,6 +219,7 @@ func main() {
// Start the WebSocket hub
go gateway.Hub.Run(ctx)
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
// Set up historical logs provider for task subscriptions
gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
+21 -1
View File
@@ -9,6 +9,27 @@ const (
TaskDeleted = "task.deleted"
)
// Event types for workspaces
const (
WorkspaceCreated = "workspace.created"
WorkspaceUpdated = "workspace.updated"
WorkspaceDeleted = "workspace.deleted"
)
// Event types for boards
const (
BoardCreated = "board.created"
BoardUpdated = "board.updated"
BoardDeleted = "board.deleted"
)
// Event types for columns
const (
ColumnCreated = "column.created"
ColumnUpdated = "column.updated"
ColumnDeleted = "column.deleted"
)
// Event types for agents
const (
AgentStarted = "agent.started"
@@ -33,4 +54,3 @@ func BuildACPSubject(taskID string) string {
func BuildACPWildcardSubject() string {
return ACPMessage + ".*"
}
@@ -0,0 +1,70 @@
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 TaskEventBroadcaster struct {
hub *Hub
subscriptions []bus.Subscription
logger *logger.Logger
}
func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *TaskEventBroadcaster {
b := &TaskEventBroadcaster{
hub: hub,
logger: log.WithFields(zap.String("component", "ws-task-broadcaster")),
}
if eventBus == nil {
return b
}
b.subscribe(eventBus, events.WorkspaceCreated, ws.ActionWorkspaceCreated)
b.subscribe(eventBus, events.WorkspaceUpdated, ws.ActionWorkspaceUpdated)
b.subscribe(eventBus, events.WorkspaceDeleted, ws.ActionWorkspaceDeleted)
b.subscribe(eventBus, events.BoardCreated, ws.ActionBoardCreated)
b.subscribe(eventBus, events.BoardUpdated, ws.ActionBoardUpdated)
b.subscribe(eventBus, events.BoardDeleted, ws.ActionBoardDeleted)
b.subscribe(eventBus, events.ColumnCreated, ws.ActionColumnCreated)
b.subscribe(eventBus, events.ColumnUpdated, ws.ActionColumnUpdated)
b.subscribe(eventBus, events.ColumnDeleted, ws.ActionColumnDeleted)
go func() {
<-ctx.Done()
b.Close()
}()
return b
}
func (b *TaskEventBroadcaster) Close() {
for _, sub := range b.subscriptions {
if sub != nil && sub.IsValid() {
_ = sub.Unsubscribe()
}
}
b.subscriptions = nil
}
func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) {
sub, err := eventBus.Subscribe(subject, func(ctx context.Context, event *bus.Event) error {
msg, err := ws.NewNotification(action, event.Data)
if err != nil {
b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err))
return nil
}
b.hub.Broadcast(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)
}
@@ -97,6 +97,7 @@ func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumn
Name: req.Name,
Position: req.Position,
State: req.State,
Color: req.Color,
})
if err != nil {
return dto.ColumnDTO{}, err
@@ -104,6 +105,23 @@ func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumn
return dto.FromColumn(column), nil
}
func (c *BoardController) UpdateColumn(ctx context.Context, req dto.UpdateColumnRequest) (dto.ColumnDTO, error) {
column, err := c.service.UpdateColumn(ctx, req.ID, &service.UpdateColumnRequest{
Name: req.Name,
Position: req.Position,
State: req.State,
Color: req.Color,
})
if err != nil {
return dto.ColumnDTO{}, err
}
return dto.FromColumn(column), nil
}
func (c *BoardController) DeleteColumn(ctx context.Context, req dto.GetColumnRequest) error {
return c.service.DeleteColumn(ctx, req.ID)
}
func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) {
board, err := c.service.GetBoard(ctx, req.BoardID)
if err != nil {
+2
View File
@@ -31,6 +31,7 @@ type ColumnDTO struct {
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"`
Color string `json:"color"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -123,6 +124,7 @@ func FromColumn(column *models.Column) ColumnDTO {
Name: column.Name,
Position: column.Position,
State: column.State,
Color: column.Color,
CreatedAt: column.CreatedAt,
UpdatedAt: column.UpdatedAt,
}
@@ -61,6 +61,15 @@ type CreateColumnRequest struct {
Name string
Position int
State v1.TaskState
Color string
}
type UpdateColumnRequest struct {
ID string
Name *string
Position *int
State *v1.TaskState
Color *string
}
type GetBoardSnapshotRequest struct {
@@ -39,6 +39,12 @@ func (h *BoardHandlers) registerHTTP(router *gin.Engine) {
api.GET("/boards/:id", h.httpGetBoard)
api.GET("/boards/:id/columns", h.httpListColumns)
api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot)
api.POST("/boards", h.httpCreateBoard)
api.PATCH("/boards/:id", h.httpUpdateBoard)
api.DELETE("/boards/:id", h.httpDeleteBoard)
api.POST("/boards/:id/columns", h.httpCreateColumn)
api.PUT("/columns/:id", h.httpUpdateColumn)
api.DELETE("/columns/:id", h.httpDeleteColumn)
}
func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
@@ -50,6 +56,8 @@ func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionColumnList, h.wsListColumns)
dispatcher.RegisterFunc(ws.ActionColumnCreate, h.wsCreateColumn)
dispatcher.RegisterFunc(ws.ActionColumnGet, h.wsGetColumn)
dispatcher.RegisterFunc(ws.ActionColumnUpdate, h.wsUpdateColumn)
dispatcher.RegisterFunc(ws.ActionColumnDelete, h.wsDeleteColumn)
}
// HTTP handlers
@@ -87,6 +95,67 @@ func (h *BoardHandlers) httpGetBoard(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
type httpCreateBoardRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
func (h *BoardHandlers) httpCreateBoard(c *gin.Context) {
var body httpCreateBoardRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if body.Name == "" || body.WorkspaceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and name are required"})
return
}
resp, err := h.controller.CreateBoard(c.Request.Context(), dto.CreateBoardRequest{
WorkspaceID: body.WorkspaceID,
Name: body.Name,
Description: body.Description,
})
if err != nil {
h.logger.Error("failed to create board", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create board"})
return
}
c.JSON(http.StatusCreated, resp)
}
type httpUpdateBoardRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
func (h *BoardHandlers) httpUpdateBoard(c *gin.Context) {
var body httpUpdateBoardRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
resp, err := h.controller.UpdateBoard(c.Request.Context(), dto.UpdateBoardRequest{
ID: c.Param("id"),
Name: body.Name,
Description: body.Description,
})
if err != nil {
handleNotFound(c, h.logger, err, "board not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpDeleteBoard(c *gin.Context) {
resp, err := h.controller.DeleteBoard(c.Request.Context(), dto.DeleteBoardRequest{ID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "board not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpListColumns(c *gin.Context) {
resp, err := h.controller.ListColumns(c.Request.Context(), dto.ListColumnsRequest{BoardID: c.Param("id")})
if err != nil {
@@ -96,6 +165,80 @@ func (h *BoardHandlers) httpListColumns(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
type httpCreateColumnRequest struct {
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"`
Color string `json:"color"`
}
func (h *BoardHandlers) httpCreateColumn(c *gin.Context) {
var body httpCreateColumnRequest
if err := c.ShouldBindJSON(&body); err != nil {
h.logger.Error("failed to bind create column request", zap.Error(err), zap.String("board_id", c.Param("id")))
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if body.Name == "" {
h.logger.Error("create column missing name", zap.String("board_id", c.Param("id")))
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
state := body.State
if state == "" {
state = v1.TaskStateTODO
}
resp, err := h.controller.CreateColumn(c.Request.Context(), dto.CreateColumnRequest{
BoardID: c.Param("id"),
Name: body.Name,
Position: body.Position,
State: state,
Color: body.Color,
})
if err != nil {
h.logger.Error("failed to create column", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create column"})
return
}
c.JSON(http.StatusCreated, resp)
}
type httpUpdateColumnRequest struct {
Name *string `json:"name"`
Position *int `json:"position"`
State *v1.TaskState `json:"state"`
Color *string `json:"color"`
}
func (h *BoardHandlers) httpUpdateColumn(c *gin.Context) {
var body httpUpdateColumnRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
resp, err := h.controller.UpdateColumn(c.Request.Context(), dto.UpdateColumnRequest{
ID: c.Param("id"),
Name: body.Name,
Position: body.Position,
State: body.State,
Color: body.Color,
})
if err != nil {
handleNotFound(c, h.logger, err, "column not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpDeleteColumn(c *gin.Context) {
if err := h.controller.DeleteColumn(c.Request.Context(), dto.GetColumnRequest{ID: c.Param("id")}); err != nil {
h.logger.Error("failed to delete column", zap.Error(err), zap.String("column_id", c.Param("id")))
handleNotFound(c, h.logger, err, "column not found")
return
}
c.Status(http.StatusNoContent)
}
func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) {
resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetBoardSnapshotRequest{BoardID: c.Param("id")})
if err != nil {
@@ -248,6 +391,7 @@ type wsCreateColumnRequest struct {
Name string `json:"name"`
Position int `json:"position"`
State string `json:"state,omitempty"`
Color string `json:"color,omitempty"`
}
func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -272,6 +416,7 @@ func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*w
Name: req.Name,
Position: req.Position,
State: state,
Color: req.Color,
})
if err != nil {
h.logger.Error("failed to create column", zap.Error(err))
@@ -299,3 +444,52 @@ func (h *BoardHandlers) wsGetColumn(ctx context.Context, msg *ws.Message) (*ws.M
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsUpdateColumnRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
Color *string `json:"color,omitempty"`
}
func (h *BoardHandlers) wsUpdateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsUpdateColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.UpdateColumn(ctx, dto.UpdateColumnRequest{
ID: req.ID,
Name: req.Name,
Position: req.Position,
State: req.State,
Color: req.Color,
})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsDeleteColumnRequest struct {
ID string `json:"id"`
}
func (h *BoardHandlers) wsDeleteColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsDeleteColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if err := h.controller.DeleteColumn(ctx, dto.GetColumnRequest{ID: req.ID}); err != nil {
h.logger.Error("failed to delete column (ws)", zap.Error(err), zap.String("column_id", req.ID))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true})
}
@@ -53,6 +53,7 @@ type Column struct {
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"` // Maps column to task state
Color string `json:"color"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+21 -16
View File
@@ -108,6 +108,7 @@ func (r *SQLiteRepository) initSchema() error {
name TEXT NOT NULL,
position INTEGER DEFAULT 0,
state TEXT DEFAULT 'TODO',
color TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
@@ -187,6 +188,9 @@ func (r *SQLiteRepository) initSchema() error {
if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("columns", "color", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := r.ensureDefaultWorkspace(); err != nil {
return err
@@ -283,18 +287,19 @@ func (r *SQLiteRepository) ensureDefaultWorkspace() error {
name string
position int
state string
color string
}
columns := []columnSeed{
{name: "Todo", position: 0, state: string(v1.TaskStateTODO)},
{name: "In Progress", position: 1, state: string(v1.TaskStateInProgress)},
{name: "Review", position: 2, state: string(v1.TaskStateReview)},
{name: "Done", position: 3, state: string(v1.TaskStateCompleted)},
{name: "Todo", position: 0, state: string(v1.TaskStateTODO), color: "bg-neutral-400"},
{name: "In Progress", position: 1, state: string(v1.TaskStateInProgress), color: "bg-blue-500"},
{name: "Review", position: 2, state: string(v1.TaskStateReview), color: "bg-yellow-500"},
{name: "Done", position: 3, state: string(v1.TaskStateCompleted), color: "bg-green-500"},
}
for _, column := range columns {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), boardID, column.name, column.position, column.state, now, now); err != nil {
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), boardID, column.name, column.position, column.state, column.color, now, now); err != nil {
return err
}
}
@@ -802,9 +807,9 @@ func (r *SQLiteRepository) CreateColumn(ctx context.Context, column *models.Colu
column.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, column.ID, column.BoardID, column.Name, column.Position, column.State, column.CreatedAt, column.UpdatedAt)
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, column.ID, column.BoardID, column.Name, column.Position, column.State, column.Color, column.CreatedAt, column.UpdatedAt)
return err
}
@@ -814,9 +819,9 @@ func (r *SQLiteRepository) GetColumn(ctx context.Context, id string) (*models.Co
column := &models.Column{}
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, name, position, state, created_at, updated_at
SELECT id, board_id, name, position, state, color, created_at, updated_at
FROM columns WHERE id = ?
`, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt)
`, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("column not found: %s", id)
@@ -829,8 +834,8 @@ func (r *SQLiteRepository) UpdateColumn(ctx context.Context, column *models.Colu
column.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE columns SET name = ?, position = ?, state = ?, updated_at = ? WHERE id = ?
`, column.Name, column.Position, column.State, column.UpdatedAt, column.ID)
UPDATE columns SET name = ?, position = ?, state = ?, color = ?, updated_at = ? WHERE id = ?
`, column.Name, column.Position, column.State, column.Color, column.UpdatedAt, column.ID)
if err != nil {
return err
}
@@ -859,7 +864,7 @@ func (r *SQLiteRepository) DeleteColumn(ctx context.Context, id string) error {
// ListColumns returns all columns for a board
func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, board_id, name, position, state, created_at, updated_at
SELECT id, board_id, name, position, state, color, created_at, updated_at
FROM columns WHERE board_id = ? ORDER BY position
`, boardID)
if err != nil {
@@ -870,7 +875,7 @@ func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]*
var result []*models.Column
for rows.Next() {
column := &models.Column{}
err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt)
err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
if err != nil {
return nil, err
}
@@ -92,6 +92,15 @@ type CreateColumnRequest struct {
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"`
Color string `json:"color"`
}
// UpdateColumnRequest contains the data for updating a column
type UpdateColumnRequest struct {
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
Color *string `json:"color,omitempty"`
}
// Task operations
@@ -306,6 +315,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque
return nil, err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace)
s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name))
return workspace, nil
}
@@ -335,16 +345,22 @@ func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWor
return nil, err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace)
s.logger.Info("workspace updated", zap.String("workspace_id", workspace.ID))
return workspace, nil
}
// DeleteWorkspace deletes a workspace
func (s *Service) DeleteWorkspace(ctx context.Context, id string) error {
workspace, err := s.repo.GetWorkspace(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteWorkspace(ctx, id); err != nil {
s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err))
return err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace)
s.logger.Info("workspace deleted", zap.String("workspace_id", id))
return nil
}
@@ -370,6 +386,7 @@ func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*mo
return nil, err
}
s.publishBoardEvent(ctx, events.BoardCreated, board)
s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name))
return board, nil
}
@@ -399,17 +416,23 @@ func (s *Service) UpdateBoard(ctx context.Context, id string, req *UpdateBoardRe
return nil, err
}
s.publishBoardEvent(ctx, events.BoardUpdated, board)
s.logger.Info("board updated", zap.String("board_id", board.ID))
return board, nil
}
// DeleteBoard deletes a board
func (s *Service) DeleteBoard(ctx context.Context, id string) error {
board, err := s.repo.GetBoard(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteBoard(ctx, id); err != nil {
s.logger.Error("failed to delete board", zap.String("board_id", id), zap.Error(err))
return err
}
s.publishBoardEvent(ctx, events.BoardDeleted, board)
s.logger.Info("board deleted", zap.String("board_id", id))
return nil
}
@@ -423,12 +446,17 @@ func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models
// CreateColumn creates a new column
func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*models.Column, error) {
color := req.Color
if color == "" {
color = "bg-neutral-400"
}
column := &models.Column{
ID: uuid.New().String(),
BoardID: req.BoardID,
Name: req.Name,
Position: req.Position,
State: req.State,
Color: color,
}
if err := s.repo.CreateColumn(ctx, column); err != nil {
@@ -436,6 +464,7 @@ func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*
return nil, err
}
s.publishColumnEvent(ctx, events.ColumnCreated, column)
s.logger.Info("column created",
zap.String("column_id", column.ID),
zap.String("board_id", column.BoardID),
@@ -453,6 +482,55 @@ func (s *Service) ListColumns(ctx context.Context, boardID string) ([]*models.Co
return s.repo.ListColumns(ctx, boardID)
}
// UpdateColumn updates an existing column
func (s *Service) UpdateColumn(ctx context.Context, id string, req *UpdateColumnRequest) (*models.Column, error) {
column, err := s.repo.GetColumn(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
column.Name = *req.Name
}
if req.Position != nil {
column.Position = *req.Position
}
if req.State != nil {
column.State = *req.State
}
if req.Color != nil {
column.Color = *req.Color
}
column.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateColumn(ctx, column); err != nil {
s.logger.Error("failed to update column", zap.Error(err))
return nil, err
}
s.publishColumnEvent(ctx, events.ColumnUpdated, column)
s.logger.Info("column updated",
zap.String("column_id", column.ID),
zap.String("board_id", column.BoardID),
zap.String("name", column.Name))
return column, nil
}
// DeleteColumn deletes an existing column
func (s *Service) DeleteColumn(ctx context.Context, id string) error {
column, err := s.repo.GetColumn(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteColumn(ctx, id); err != nil {
s.logger.Error("failed to delete column", zap.Error(err))
return err
}
s.publishColumnEvent(ctx, events.ColumnDeleted, column)
s.logger.Info("column deleted", zap.String("column_id", id))
return nil
}
// publishTaskEvent publishes task events to the event bus
func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) {
if s.eventBus == nil {
@@ -496,3 +574,74 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
zap.Error(err))
}
}
func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, workspace *models.Workspace) {
if s.eventBus == nil || workspace == nil {
return
}
data := map[string]interface{}{
"id": workspace.ID,
"name": workspace.Name,
"description": workspace.Description,
"owner_id": workspace.OwnerID,
"created_at": workspace.CreatedAt.Format(time.RFC3339),
"updated_at": workspace.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish workspace event",
zap.String("event_type", eventType),
zap.String("workspace_id", workspace.ID),
zap.Error(err))
}
}
func (s *Service) publishBoardEvent(ctx context.Context, eventType string, board *models.Board) {
if s.eventBus == nil || board == nil {
return
}
data := map[string]interface{}{
"id": board.ID,
"workspace_id": board.WorkspaceID,
"name": board.Name,
"description": board.Description,
"created_at": board.CreatedAt.Format(time.RFC3339),
"updated_at": board.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish board event",
zap.String("event_type", eventType),
zap.String("board_id", board.ID),
zap.Error(err))
}
}
func (s *Service) publishColumnEvent(ctx context.Context, eventType string, column *models.Column) {
if s.eventBus == nil || column == nil {
return
}
data := map[string]interface{}{
"id": column.ID,
"board_id": column.BoardID,
"name": column.Name,
"position": column.Position,
"state": string(column.State),
"color": column.Color,
"created_at": column.CreatedAt.Format(time.RFC3339),
"updated_at": column.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish column event",
zap.String("event_type", eventType),
zap.String("column_id", column.ID),
zap.Error(err))
}
}
+11
View File
@@ -23,6 +23,8 @@ const (
ActionColumnList = "column.list"
ActionColumnCreate = "column.create"
ActionColumnGet = "column.get"
ActionColumnUpdate = "column.update"
ActionColumnDelete = "column.delete"
// Task actions
ActionTaskList = "task.list"
@@ -69,6 +71,15 @@ const (
ActionACPHeartbeat = "acp.heartbeat"
ActionTaskUpdated = "task.updated"
ActionAgentUpdated = "agent.updated"
ActionWorkspaceCreated = "workspace.created"
ActionWorkspaceUpdated = "workspace.updated"
ActionWorkspaceDeleted = "workspace.deleted"
ActionBoardCreated = "board.created"
ActionBoardUpdated = "board.updated"
ActionBoardDeleted = "board.deleted"
ActionColumnCreated = "column.created"
ActionColumnUpdated = "column.updated"
ActionColumnDeleted = "column.deleted"
)
// Error codes
+118
View File
@@ -0,0 +1,118 @@
'use server';
import { getBackendConfig } from '@/lib/config';
import type {
Board,
Column,
ListBoardsResponse,
ListColumnsResponse,
ListWorkspacesResponse,
Workspace,
} from '@/lib/types/http';
const { apiBaseUrl } = getBackendConfig();
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export async function listWorkspacesAction(): Promise<ListWorkspacesResponse> {
return fetchJson<ListWorkspacesResponse>(`${apiBaseUrl}/api/v1/workspaces`);
}
export async function getWorkspaceAction(id: string): Promise<Workspace> {
return fetchJson<Workspace>(`${apiBaseUrl}/api/v1/workspaces/${id}`);
}
export async function createWorkspaceAction(payload: { name: string; description?: string }) {
return fetchJson<Workspace>(`${apiBaseUrl}/api/v1/workspaces`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateWorkspaceAction(id: string, payload: { name?: string; description?: string }) {
return fetchJson<Workspace>(`${apiBaseUrl}/api/v1/workspaces/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function deleteWorkspaceAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/workspaces/${id}`, { method: 'DELETE' });
}
export async function listBoardsAction(workspaceId: string): Promise<ListBoardsResponse> {
const url = new URL(`${apiBaseUrl}/api/v1/boards`);
url.searchParams.set('workspace_id', workspaceId);
return fetchJson<ListBoardsResponse>(url.toString());
}
export async function createBoardAction(payload: { workspace_id: string; name: string; description?: string }) {
return fetchJson<Board>(`${apiBaseUrl}/api/v1/boards`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateBoardAction(id: string, payload: { name?: string; description?: string }) {
return fetchJson<Board>(`${apiBaseUrl}/api/v1/boards/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function deleteBoardAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/boards/${id}`, { method: 'DELETE' });
}
export async function listColumnsAction(boardId: string): Promise<ListColumnsResponse> {
return fetchJson<ListColumnsResponse>(`${apiBaseUrl}/api/v1/boards/${boardId}/columns`);
}
export async function createColumnAction(payload: {
board_id: string;
name: string;
position: number;
state: string;
color: string;
}) {
return fetchJson<Column>(`${apiBaseUrl}/api/v1/boards/${payload.board_id}/columns`, {
method: 'POST',
body: JSON.stringify({
name: payload.name,
position: payload.position,
state: payload.state,
color: payload.color,
}),
});
}
export async function updateColumnAction(id: string, payload: Partial<Pick<Column, 'name' | 'position' | 'state' | 'color'>>) {
return fetchJson<Column>(`${apiBaseUrl}/api/v1/columns/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteColumnAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' });
}
+5 -2
View File
@@ -3,6 +3,7 @@ import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { StateProvider } from "@/components/state-provider";
import { WebSocketConnector } from "@/components/ws-connector";
import { ToastProvider } from "@/components/toast-provider";
export const metadata: Metadata = {
title: "Kandev - AI Kanban",
@@ -19,8 +20,10 @@ export default function RootLayout({
<body className="antialiased font-sans">
<StateProvider>
<ThemeProvider>
<WebSocketConnector />
{children}
<ToastProvider>
<WebSocketConnector />
{children}
</ToastProvider>
</ThemeProvider>
</StateProvider>
</body>
+29 -3
View File
@@ -4,10 +4,23 @@ import { fetchBoardSnapshot, fetchBoards, fetchWorkspaces } from '@/lib/ssr/http
import { snapshotToState } from '@/lib/ssr/mapper';
// Server Component: runs on the server for SSR and data hydration.
export default async function Page() {
type PageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
export default async function Page({ searchParams }: PageProps) {
try {
const resolvedParams = searchParams ? await searchParams : {};
const workspaceParam = resolvedParams.workspaceId;
const boardParam = resolvedParams.boardId;
const workspaceId = Array.isArray(workspaceParam) ? workspaceParam[0] : workspaceParam;
const boardIdParam = Array.isArray(boardParam) ? boardParam[0] : boardParam;
const workspaces = await fetchWorkspaces();
const activeWorkspaceId = workspaces.workspaces[0]?.id ?? null;
const activeWorkspaceId =
workspaces.workspaces.find((workspace) => workspace.id === workspaceId)?.id ??
workspaces.workspaces[0]?.id ??
null;
let initialState = {
workspaces: {
items: workspaces.workspaces.map((workspace) => ({
@@ -28,7 +41,20 @@ export default async function Page() {
}
const boardList = await fetchBoards(activeWorkspaceId);
const boardId = boardList.boards[0]?.id;
const boardId =
boardList.boards.find((board) => board.id === boardIdParam)?.id ?? boardList.boards[0]?.id;
initialState = {
...initialState,
boards: {
items: boardList.boards.map((board) => ({
id: board.id,
workspaceId: board.workspace_id,
name: board.name,
})),
activeId: boardId ?? null,
},
};
if (!boardId) {
return (
<>
+29 -32
View File
@@ -1,13 +1,6 @@
'use client';
import { IconArrowLeft } from '@tabler/icons-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from '@/components/ui/breadcrumb';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
import { SettingsLayoutClient } from '@/components/settings/settings-layout-client';
import { StateHydrator } from '@/components/state-hydrator';
import { fetchWorkspaces } from '@/lib/ssr/http';
export default function SettingsLayout({
children,
@@ -15,27 +8,31 @@ export default function SettingsLayout({
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<SettingsAppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/" className="flex items-center gap-2">
<IconArrowLeft className="h-4 w-4" />
Home
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
{children}
</div>
</SidebarInset>
</SidebarProvider>
<SettingsLayoutServer>{children}</SettingsLayoutServer>
);
}
async function SettingsLayoutServer({ children }: { children: React.ReactNode }) {
let initialState = {};
try {
const workspaces = await fetchWorkspaces();
initialState = {
workspaces: {
items: workspaces.workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
})),
activeId: workspaces.workspaces[0]?.id ?? null,
},
};
} catch {
initialState = {};
}
return (
<>
<StateHydrator initialState={initialState} />
<SettingsLayoutClient>{children}</SettingsLayoutClient>
</>
);
}
+18 -373
View File
@@ -1,379 +1,24 @@
'use client';
import { getWorkspaceAction, listBoardsAction, listColumnsAction } from '@/app/actions/workspaces';
import { WorkspaceEditClient } from '@/app/settings/workspace/workspace-edit-client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IconGitBranch, IconFolder, IconPlus, IconTrash } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { RepositoryCard } from '@/components/settings/repository-card';
import { ContextCard } from '@/components/settings/context-card';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Workspace, Repository, Context } from '@/lib/settings/types';
export default async function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let workspace = null;
let boardsWithColumns: Array<Awaited<ReturnType<typeof listBoardsAction>>['boards'][number] & { columns: Awaited<ReturnType<typeof listColumnsAction>>['columns'] }> = [];
export default function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [workspace, setWorkspace] = useState<Workspace | undefined>(
SETTINGS_DATA.workspaces.find((w) => w.id === id)
);
const [isAddingRepo, setIsAddingRepo] = useState(false);
const [isAddingContext, setIsAddingContext] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
if (!workspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/workspace/new')}>
Create New Workspace
</Button>
</CardContent>
</Card>
</div>
try {
workspace = await getWorkspaceAction(id);
const boards = await listBoardsAction(id);
boardsWithColumns = await Promise.all(
boards.boards.map(async (board) => {
const columns = await listColumnsAction(board.id);
return { ...board, columns: columns.columns };
})
);
} catch {
workspace = null;
boardsWithColumns = [];
}
const handleUpdateWorkspaceName = (name: string) => {
setWorkspace({ ...workspace, name });
};
const handleAddRepository = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newRepo: Repository = {
id: crypto.randomUUID(),
name: formData.get('name') as string,
path: formData.get('path') as string,
setupScript: formData.get('setupScript') as string,
cleanupScript: formData.get('cleanupScript') as string,
customScripts: [],
};
setWorkspace({
...workspace,
repositories: [...workspace.repositories, newRepo],
});
setIsAddingRepo(false);
};
const handleUpdateRepository = (repo: Repository) => {
setWorkspace({
...workspace,
repositories: workspace.repositories.map((r) => (r.id === repo.id ? repo : r)),
});
};
const handleDeleteRepository = (id: string) => {
setWorkspace({
...workspace,
repositories: workspace.repositories.filter((r) => r.id !== id),
});
};
const handleAddContext = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newContext: Context = {
id: crypto.randomUUID(),
name: formData.get('name') as string,
columns: [
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
};
setWorkspace({
...workspace,
contexts: [...workspace.contexts, newContext],
});
setIsAddingContext(false);
};
const handleUpdateContext = (context: Context) => {
setWorkspace({
...workspace,
contexts: workspace.contexts.map((c) => (c.id === context.id ? context : c)),
});
};
const handleDeleteContext = (id: string) => {
setWorkspace({
...workspace,
contexts: workspace.contexts.filter((c) => c.id !== id),
});
};
const handleFolderPick = (callback: (path: string) => void) => {
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.onchange = (event: Event) => {
const target = event.target as HTMLInputElement | null;
const files = target?.files;
if (files && files[0]) {
const fullPath = files[0].webkitRelativePath;
const folderName = fullPath.split('/')[0];
callback(`/${folderName}`);
}
};
input.click();
};
const handleDeleteWorkspace = () => {
if (deleteConfirmText === 'delete') {
// In real app, this would delete from database
router.push('/settings/general');
setDeleteDialogOpen(false);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{workspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage repositories and contexts for this workspace
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle>Workspace Name</CardTitle>
</CardHeader>
<CardContent>
<Input
value={workspace.name}
onChange={(e) => handleUpdateWorkspaceName(e.target.value)}
/>
</CardContent>
</Card>
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Repositories"
description="Repositories in this workspace"
action={
<Button size="sm" onClick={() => setIsAddingRepo(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Repository
</Button>
}
>
<div className="grid gap-3">
{isAddingRepo && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddRepository} className="space-y-4">
<div className="space-y-2">
<Label>Repository Name</Label>
<Input name="name" placeholder="my-project" required />
</div>
<div className="space-y-2">
<Label>Directory Path</Label>
<div className="flex gap-2">
<Input
id="repo-path"
name="path"
placeholder="/path/to/repository"
required
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleFolderPick((path) => {
const input = document.getElementById('repo-path') as HTMLInputElement;
if (input) input.value = path;
})
}
>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Setup Script</Label>
<Textarea
name="setupScript"
placeholder="#!/bin/bash&#10;npm install"
rows={3}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>Cleanup Script</Label>
<Textarea
name="cleanupScript"
placeholder="#!/bin/bash&#10;rm -rf node_modules"
rows={3}
className="font-mono text-sm"
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setIsAddingRepo(false)}>
Cancel
</Button>
<Button type="submit">Add</Button>
</div>
</form>
</CardContent>
</Card>
)}
{workspace.repositories.map((repo) => (
<RepositoryCard
key={repo.id}
repository={repo}
onUpdate={handleUpdateRepository}
onDelete={() => handleDeleteRepository(repo.id)}
/>
))}
{workspace.repositories.length === 0 && !isAddingRepo && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">No repositories in this workspace</p>
</CardContent>
</Card>
)}
</div>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconFolder className="h-5 w-5" />}
title="Contexts"
description="Kanban view contexts for this workspace"
action={
<Button size="sm" onClick={() => setIsAddingContext(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Context
</Button>
}
>
<div className="grid gap-3">
{isAddingContext && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddContext} className="space-y-4">
<div className="space-y-2">
<Label>Context Name</Label>
<Input name="name" placeholder="Dev" required />
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => setIsAddingContext(false)}
>
Cancel
</Button>
<Button type="submit">Add</Button>
</div>
</form>
</CardContent>
</Card>
)}
{workspace.contexts.map((context) => (
<ContextCard
key={context.id}
context={context}
onUpdate={handleUpdateContext}
onDelete={() => handleDeleteContext(context.id)}
/>
))}
{workspace.contexts.length === 0 && !isAddingContext && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">No contexts in this workspace</p>
</CardContent>
</Card>
)}
</div>
</SettingsSection>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently delete this workspace
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this workspace</p>
<p className="text-sm text-muted-foreground">
Once deleted, all repositories and contexts will be permanently removed
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Workspace
</Button>
</div>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Workspace</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the workspace &quot;
{workspace.name}&quot; and all its data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteWorkspace}
disabled={deleteConfirmText !== 'delete'}
>
Delete Workspace
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
return <WorkspaceEditClient workspace={workspace} boards={boardsWithColumns} />;
}
+5 -114
View File
@@ -1,116 +1,7 @@
'use client';
import { fetchWorkspaces } from '@/lib/ssr/http';
import { WorkspacesPageClient } from '@/app/settings/workspace/workspaces-page-client';
import { useState } from 'react';
import Link from 'next/link';
import { IconFolder, IconPlus, IconChevronRight } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Workspace } from '@/lib/settings/types';
export default function WorkspacesPage() {
const [workspaces, setWorkspaces] = useState<Workspace[]>(SETTINGS_DATA.workspaces);
const [isAdding, setIsAdding] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState('');
const handleAddWorkspace = (e: React.FormEvent) => {
e.preventDefault();
if (newWorkspaceName.trim()) {
const newWorkspace: Workspace = {
id: crypto.randomUUID(),
name: newWorkspaceName,
repositories: [],
contexts: [],
};
setWorkspaces([...workspaces, newWorkspace]);
setNewWorkspaceName('');
setIsAdding(false);
}
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">Workspaces</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage your workspaces with repositories and contexts
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Workspace
</Button>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-3">
{isAdding && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddWorkspace} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Workspace Name</Label>
<Input
id="workspace-name"
value={newWorkspaceName}
onChange={(e) => setNewWorkspaceName(e.target.value)}
placeholder="My Workspace"
required
autoFocus
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
Cancel
</Button>
<Button type="submit">Add Workspace</Button>
</div>
</form>
</CardContent>
</Card>
)}
{workspaces.map((workspace) => (
<Link key={workspace.id} href={`/settings/workspace/${workspace.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconFolder className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium">{workspace.name}</h4>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{workspace.repositories.length} repositories</span>
<span>{workspace.contexts.length} contexts</span>
</div>
</div>
</div>
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
))}
{workspaces.length === 0 && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No workspaces configured. Add your first workspace to get started.
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
export default async function WorkspacesPage() {
const workspaces = await fetchWorkspaces();
return <WorkspacesPageClient workspaces={workspaces.workspaces} />;
}
@@ -0,0 +1,522 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IconGitBranch, IconPlus, IconTrash } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { RepositoryCard } from '@/components/settings/repository-card';
import { BoardCard } from '@/components/settings/board-card';
import {
createBoardAction,
createColumnAction,
deleteBoardAction,
deleteColumnAction,
deleteWorkspaceAction,
updateBoardAction,
updateColumnAction,
updateWorkspaceAction,
} from '@/app/actions/workspaces';
import type { Board, Column, Workspace } from '@/lib/types/http';
import type { Repository } from '@/lib/settings/types';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
type BoardWithColumns = Board & { columns: Column[] };
type WorkspaceEditClientProps = {
workspace: Workspace | null;
boards: BoardWithColumns[];
};
export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientProps) {
const router = useRouter();
const { toast } = useToast();
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(workspace);
const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace?.name ?? '');
const [boardItems, setBoardItems] = useState<BoardWithColumns[]>(boards);
const [savedWorkspaceName, setSavedWorkspaceName] = useState(workspace?.name ?? '');
const [savedBoardItems, setSavedBoardItems] = useState<BoardWithColumns[]>(boards);
const [repositories, setRepositories] = useState<Repository[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
const handleSaveWorkspaceName = async () => {
if (!currentWorkspace) return;
const trimmed = workspaceNameDraft.trim();
if (!trimmed || trimmed === currentWorkspace.name) return;
try {
const updated = await saveWorkspaceRequest.run(currentWorkspace.id, { name: trimmed });
setCurrentWorkspace((prev) => (prev ? { ...prev, ...updated } : prev));
setSavedWorkspaceName(updated.name);
} catch (error) {
toast({
title: 'Failed to save workspace',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const cloneBoard = (board: BoardWithColumns): BoardWithColumns => ({
...board,
columns: board.columns.map((column) => ({ ...column })),
});
const savedBoardsById = useMemo(() => {
return new Map(savedBoardItems.map((board) => [board.id, board]));
}, [savedBoardItems]);
const isBoardNameDirty = (board: BoardWithColumns) => {
const saved = savedBoardsById.get(board.id);
if (!saved) return true;
if (board.name !== saved.name || board.description !== saved.description) return true;
return false;
};
const areColumnsDirty = (board: BoardWithColumns) => {
const saved = savedBoardsById.get(board.id);
if (!saved) {
return board.columns.length > 0;
}
if (board.columns.length !== saved.columns.length) return true;
const savedColumns = new Map(saved.columns.map((column) => [column.id, column]));
for (const column of board.columns) {
const savedColumn = savedColumns.get(column.id);
if (!savedColumn) return true;
if (
column.name !== savedColumn.name ||
column.color !== savedColumn.color ||
column.position !== savedColumn.position ||
column.state !== savedColumn.state
) {
return true;
}
}
return false;
};
const isWorkspaceDirty = workspaceNameDraft.trim() !== savedWorkspaceName;
const handleAddRepository = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newRepo: Repository = {
id: crypto.randomUUID(),
name: formData.get('name') as string,
path: formData.get('path') as string,
setupScript: formData.get('setupScript') as string,
cleanupScript: formData.get('cleanupScript') as string,
customScripts: [],
};
setRepositories((prev) => [...prev, newRepo]);
};
const handleUpdateRepository = (repo: Repository) => {
setRepositories((prev) => prev.map((item) => (item.id === repo.id ? repo : item)));
};
const handleDeleteRepository = (id: string) => {
setRepositories((prev) => prev.filter((repo) => repo.id !== id));
};
const handleAddBoard = () => {
if (!currentWorkspace) return;
const draftBoardId = `temp-${crypto.randomUUID()}`;
const draftBoard: BoardWithColumns = {
id: draftBoardId,
workspace_id: currentWorkspace.id,
name: 'New Board',
description: '',
created_at: '',
updated_at: '',
columns: [
{
id: `temp-col-${crypto.randomUUID()}`,
board_id: draftBoardId,
name: 'Todo',
position: 0,
state: 'TODO',
color: 'bg-cyan-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${crypto.randomUUID()}`,
board_id: draftBoardId,
name: 'In Progress',
position: 1,
state: 'IN_PROGRESS',
color: 'bg-yellow-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${crypto.randomUUID()}`,
board_id: draftBoardId,
name: 'To Review',
position: 2,
state: 'REVIEW',
color: 'bg-green-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${crypto.randomUUID()}`,
board_id: draftBoardId,
name: 'Done',
position: 3,
state: 'COMPLETED',
color: 'bg-indigo-500',
created_at: '',
updated_at: '',
},
],
};
setBoardItems((prev) => [draftBoard, ...prev]);
};
const handleUpdateBoard = (boardId: string, updates: { name?: string; description?: string }) => {
setBoardItems((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, ...updates } : board))
);
};
const handleDeleteBoard = async (boardId: string) => {
if (boardId.startsWith('temp-')) {
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
return;
}
await deleteBoardAction(boardId);
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
setSavedBoardItems((prev) => prev.filter((board) => board.id !== boardId));
};
const handleCreateColumn = (
boardId: string,
column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>
) => {
const draftColumn: Column = {
id: `temp-col-${crypto.randomUUID()}`,
board_id: boardId,
name: column.name,
position: column.position,
state: column.state,
color: column.color,
created_at: '',
updated_at: '',
};
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: [...board.columns, draftColumn] } : board
)
);
};
const handleUpdateColumn = (boardId: string, columnId: string, updates: Partial<Column>) => {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? {
...board,
columns: board.columns.map((column) => (column.id === columnId ? { ...column, ...updates } : column)),
}
: board
)
);
};
const handleDeleteColumn = async (boardId: string, columnId: string) => {
if (boardId.startsWith('temp-')) {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: board.columns.filter((column) => column.id !== columnId) } : board
)
);
return;
}
await deleteColumnAction(columnId);
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: board.columns.filter((column) => column.id !== columnId) } : board
)
);
};
const handleReorderColumns = (boardId: string, columns: Column[]) => {
setBoardItems((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, columns } : board))
);
};
const handleSaveBoard = async (boardId: string) => {
const board = boardItems.find((item) => item.id === boardId);
if (!board) return;
if (boardId.startsWith('temp-')) {
const name = board.name.trim() || 'New Board';
const createdBoard = await createBoardAction({
workspace_id: currentWorkspace?.id ?? board.workspace_id,
name,
});
const createdColumns = await Promise.all(
board.columns.map((column, index) =>
createColumnAction({
board_id: createdBoard.id,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
})
)
);
const nextBoard = {
...createdBoard,
columns: createdColumns,
};
setBoardItems((prev) =>
prev.map((item) => (item.id === boardId ? nextBoard : item))
);
setSavedBoardItems((prev) => [cloneBoard(nextBoard), ...prev]);
return;
}
const updates: { name?: string; description?: string } = {};
if (board.name.trim()) {
updates.name = board.name.trim();
}
if (Object.keys(updates).length) {
await updateBoardAction(boardId, updates);
}
const nextColumns = await Promise.all(
board.columns.map((column, index) => {
if (column.id.startsWith('temp-col-')) {
return createColumnAction({
board_id: boardId,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
});
}
return updateColumnAction(column.id, {
name: column.name,
color: column.color,
position: column.position ?? index,
state: column.state,
});
})
);
setBoardItems((prev) =>
prev.map((item) => (item.id === boardId ? { ...item, columns: nextColumns } : item))
);
setSavedBoardItems((prev) =>
prev.some((item) => item.id === boardId)
? prev.map((item) =>
item.id === boardId ? cloneBoard({ ...board, columns: nextColumns }) : item
)
: [...prev, cloneBoard({ ...board, columns: nextColumns })]
);
};
const handleDeleteWorkspace = async () => {
if (deleteConfirmText !== 'delete') return;
try {
await deleteWorkspaceRequest.run(currentWorkspace.id);
router.push('/settings/workspace');
} catch (error) {
toast({
title: 'Failed to delete workspace',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
if (!currentWorkspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/workspace')}>
Back to Workspaces
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{currentWorkspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage repositories and boards for this workspace
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>Workspace Name</span>
{isWorkspaceDirty && <UnsavedChangesBadge />}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Input
value={workspaceNameDraft}
onChange={(e) => setWorkspaceNameDraft(e.target.value)}
/>
<div className="flex items-center gap-2">
<UnsavedSaveButton
isDirty={isWorkspaceDirty}
isLoading={saveWorkspaceRequest.isLoading}
status={saveWorkspaceRequest.status}
onClick={handleSaveWorkspaceName}
/>
</div>
</div>
</CardContent>
</Card>
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Repositories"
description="Repositories in this workspace"
>
<div className="grid gap-3">
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddRepository} className="space-y-4">
<div className="space-y-2">
<Label>Repository Name</Label>
<Input name="name" placeholder="my-project" required />
</div>
<div className="space-y-2">
<Label>Directory Path</Label>
<Input name="path" placeholder="/path/to/repository" required />
</div>
<div className="space-y-2">
<Label>Setup Script</Label>
<Input name="setupScript" placeholder="npm install" />
</div>
<div className="space-y-2">
<Label>Cleanup Script</Label>
<Input name="cleanupScript" placeholder="npm run cleanup" />
</div>
<div className="flex justify-end">
<Button type="submit">Add Repository</Button>
</div>
</form>
</CardContent>
</Card>
{repositories.map((repo) => (
<RepositoryCard
key={repo.id}
repository={repo}
onUpdate={handleUpdateRepository}
onDelete={() => handleDeleteRepository(repo.id)}
/>
))}
</div>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Boards"
description="Boards and columns in this workspace"
action={
<Button size="sm" onClick={handleAddBoard}>
<IconPlus className="h-4 w-4 mr-2" />
Add Board
</Button>
}
>
<div className="grid gap-3">
{boardItems.map((board) => (
<BoardCard
key={board.id}
board={board}
columns={board.columns}
isBoardDirty={isBoardNameDirty(board)}
areColumnsDirty={areColumnsDirty(board)}
onUpdateBoard={(updates) => handleUpdateBoard(board.id, updates)}
onDeleteBoard={() => handleDeleteBoard(board.id)}
onCreateColumn={(column) => handleCreateColumn(board.id, column)}
onUpdateColumn={(columnId, updates) => handleUpdateColumn(board.id, columnId, updates)}
onDeleteColumn={(columnId) => handleDeleteColumn(board.id, columnId)}
onReorderColumns={(columns) => handleReorderColumns(board.id, columns)}
onSaveBoard={() => handleSaveBoard(board.id)}
/>
))}
</div>
</SettingsSection>
<Separator />
<Card>
<CardHeader>
<CardTitle className="text-destructive">Delete Workspace</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Deleting this workspace will remove all boards and tasks associated with it.
</p>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Workspace
</Button>
</div>
</CardContent>
</Card>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Workspace</DialogTitle>
<DialogDescription>
This will delete all boards and tasks. This action cannot be undone. Type &quot;delete&quot; to confirm.
</DialogDescription>
</DialogHeader>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteWorkspace}>
Delete Workspace
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,147 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { IconFolder, IconPlus, IconChevronRight } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { createWorkspaceAction } from '@/app/actions/workspaces';
import type { Workspace } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { RequestIndicator } from '@/components/request-indicator';
import { useAppStore } from '@/components/state-provider';
type WorkspacesPageClientProps = {
workspaces: Workspace[];
};
export function WorkspacesPageClient({ workspaces }: WorkspacesPageClientProps) {
const items = useAppStore((state) => state.workspaces.items);
const setWorkspaces = useAppStore((state) => state.setWorkspaces);
const [isAdding, setIsAdding] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState('');
const createRequest = useRequest(createWorkspaceAction);
const { toast } = useToast();
useEffect(() => {
if (!items.length && workspaces.length) {
setWorkspaces(
workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
}))
);
}
}, [items.length, setWorkspaces, workspaces]);
const handleAddWorkspace = async (e: React.FormEvent) => {
e.preventDefault();
if (!newWorkspaceName.trim()) return;
try {
const created = await createRequest.run({ name: newWorkspaceName.trim() });
setWorkspaces([
{ id: created.id, name: created.name },
...items.map((workspace) => ({ id: workspace.id, name: workspace.name })),
]);
setNewWorkspaceName('');
setIsAdding(false);
} catch (error) {
toast({
title: 'Failed to create workspace',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">Workspaces</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage your workspaces and boards
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Workspace
</Button>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-3">
{isAdding && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddWorkspace} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Workspace Name</Label>
<Input
id="workspace-name"
value={newWorkspaceName}
onChange={(e) => setNewWorkspaceName(e.target.value)}
placeholder="My Workspace"
required
autoFocus
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
Cancel
</Button>
<div className="flex items-center gap-2">
<RequestIndicator status={createRequest.status} />
<Button type="submit" disabled={createRequest.isLoading}>
Add Workspace
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
)}
{items.map((workspace) => (
<Link key={workspace.id} href={`/settings/workspace/${workspace.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconFolder className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium">{workspace.name}</h4>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>Boards managed in this workspace</span>
</div>
</div>
</div>
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
))}
{items.length === 0 && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No workspaces configured. Add your first workspace to get started.
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
+84 -68
View File
@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState, useSyncExternalStore } from 'react';
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import {
DndContext,
DragEndEvent,
@@ -26,45 +26,8 @@ import {
} from '@/components/ui/select';
import { useRouter } from 'next/navigation';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
const VIEWS: Record<
string,
{
label: string;
columns: Column[];
}
> = {
team: {
label: 'Team view',
columns: [
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
{ id: 'solution-design', title: 'Solution Design', color: 'bg-sky-500' },
{ id: 'ready-for-dev', title: 'Ready for Dev', color: 'bg-indigo-500' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
user: {
label: 'User view',
columns: [
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
architect: {
label: 'Architect view',
columns: [
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
{ id: 'high-level-design', title: 'High Level Design', color: 'bg-cyan-500' },
{ id: 'low-level-design', title: 'Low Level Design', color: 'bg-violet-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
};
import { getWebSocketClient } from '@/lib/ws/connection';
import type { ListBoardsResponse } from '@/lib/types/http';
const initialTasks: Task[] = [
{ id: '1', title: 'Design database schema', status: 'todo' },
@@ -76,11 +39,13 @@ export function KanbanBoard() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [activeViewId, setActiveViewId] = useState('team');
const router = useRouter();
const kanban = useAppStore((state) => state.kanban);
const workspaceState = useAppStore((state) => state.workspaces);
const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace);
const boardsState = useAppStore((state) => state.boards);
const setActiveBoard = useAppStore((state) => state.setActiveBoard);
const setBoards = useAppStore((state) => state.setBoards);
const store = useAppStoreApi();
const isMounted = useSyncExternalStore(
@@ -97,14 +62,15 @@ export function KanbanBoard() {
})
);
const activeView = VIEWS[activeViewId] ?? VIEWS.team;
const backendColumns = useMemo<Column[]>(
() =>
kanban.columns.map((column) => ({
id: column.id,
title: column.title,
color: 'bg-neutral-400',
})),
[...kanban.columns]
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.map((column) => ({
id: column.id,
title: column.title,
color: column.color || 'bg-neutral-400',
})),
[kanban.columns]
);
const backendTasks = useMemo<Task[]>(
@@ -116,7 +82,7 @@ export function KanbanBoard() {
})),
[kanban.tasks]
);
const activeColumns = kanban.boardId ? backendColumns : activeView.columns;
const activeColumns = kanban.boardId ? backendColumns : [];
const visibleTasks = kanban.boardId ? backendTasks : tasks;
const activeTask = useMemo(
() => visibleTasks.find((task) => task.id === activeTaskId) ?? null,
@@ -178,6 +144,33 @@ export function KanbanBoard() {
return visibleTasks.filter((task) => task.status === columnId);
};
useEffect(() => {
const workspaceId = workspaceState.activeId;
if (!workspaceId) return;
const client = getWebSocketClient();
if (!client) return;
client
.request<ListBoardsResponse>('board.list', { workspace_id: workspaceId })
.then((response) => {
const boards = response.boards.map((board) => ({
id: board.id,
workspaceId: board.workspace_id,
name: board.name,
}));
setBoards(boards);
const nextBoardId = boards[0]?.id ?? null;
setActiveBoard(nextBoardId);
if (!nextBoardId) {
store.getState().hydrate({
kanban: { boardId: null, columns: [], tasks: [] },
});
}
})
.catch(() => {
// Ignore board list errors for now.
});
}, [setActiveBoard, setBoards, store, workspaceState.activeId]);
if (!isMounted) {
return <div className="h-screen w-full bg-background" />;
}
@@ -186,11 +179,18 @@ export function KanbanBoard() {
<div className="h-screen w-full flex flex-col bg-background">
<header className="flex items-center justify-between p-4 pb-3">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">KanDev.ai</h1>
<Link href="/" className="text-2xl font-bold hover:opacity-80">
KanDev.ai
</Link>
<ConnectionStatus />
<Select
value={workspaceState.activeId ?? ''}
onValueChange={(value) => setActiveWorkspace(value || null)}
onValueChange={(value) => {
setActiveWorkspace(value || null);
if (value) {
router.push(`/?workspaceId=${value}`);
}
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select workspace" />
@@ -205,14 +205,24 @@ export function KanbanBoard() {
</Select>
</div>
<div className="flex items-center gap-3">
<Select value={activeViewId} onValueChange={setActiveViewId}>
<Select
value={boardsState.activeId ?? ''}
onValueChange={(value) => {
setActiveBoard(value || null);
if (value) {
const workspaceId = boardsState.items.find((board) => board.id === value)?.workspaceId;
const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : '';
router.push(`/?boardId=${value}${workspaceParam}`);
}
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select view" />
<SelectValue placeholder="Select board" />
</SelectTrigger>
<SelectContent>
{Object.entries(VIEWS).map(([id, view]) => (
<SelectItem key={id} value={id}>
{view.label}
{boardsState.items.map((board) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
</SelectItem>
))}
</SelectContent>
@@ -244,19 +254,25 @@ export function KanbanBoard() {
onDragCancel={handleDragCancel}
>
<div className="flex-1 min-h-0 px-4 pb-4">
<div
className="grid gap-[3px] rounded-lg overflow-hidden h-full p-[2px]"
style={{ gridTemplateColumns: `repeat(${activeColumns.length}, minmax(0, 1fr))` }}
>
{activeColumns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
tasks={getTasksForColumn(column.id)}
onOpenTask={handleOpenTask}
/>
))}
</div>
{activeColumns.length === 0 ? (
<div className="h-full rounded-lg border border-dashed border-border/60 flex items-center justify-center text-sm text-muted-foreground">
No boards available yet.
</div>
) : (
<div
className="grid gap-[3px] rounded-lg overflow-hidden h-full p-[2px]"
style={{ gridTemplateColumns: `repeat(${activeColumns.length}, minmax(0, 1fr))` }}
>
{activeColumns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
tasks={getTasksForColumn(column.id)}
onOpenTask={handleOpenTask}
/>
))}
</div>
)}
</div>
<DragOverlay dropAnimation={null}>
{activeTask ? <KanbanCardPreview task={activeTask} /> : null}
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { IconCheck, IconLoader2, IconX } from '@tabler/icons-react';
import type { RequestStatus } from '@/lib/http/use-request';
type RequestIndicatorProps = {
status: RequestStatus;
};
export function RequestIndicator({ status }: RequestIndicatorProps) {
if (status === 'loading') {
return <IconLoader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
}
if (status === 'success') {
return <IconCheck className="h-4 w-4 text-emerald-500" />;
}
if (status === 'error') {
return <IconX className="h-4 w-4 text-destructive" />;
}
return null;
}
+330
View File
@@ -0,0 +1,330 @@
'use client';
import { useMemo, useState, useSyncExternalStore } from 'react';
import {
DndContext,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
IconGripVertical,
IconTrash,
IconPlus,
} from '@tabler/icons-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { Board, Column } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
type BoardCardProps = {
board: Board;
columns: Column[];
isBoardDirty: boolean;
areColumnsDirty: boolean;
onUpdateBoard: (updates: { name?: string; description?: string }) => void;
onDeleteBoard: () => Promise<unknown>;
onCreateColumn: (column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>) => void;
onUpdateColumn: (columnId: string, updates: Partial<Column>) => void;
onDeleteColumn: (columnId: string) => Promise<unknown>;
onReorderColumns: (columns: Column[]) => void;
onSaveBoard: () => Promise<unknown>;
};
const COLOR_OPTIONS = [
{ value: 'bg-neutral-400', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-violet-500', label: 'Violet' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-sky-500', label: 'Sky' },
];
function SortableColumnRow({
column,
onUpdate,
onDelete,
}: {
column: Column;
onUpdate: (updates: Partial<Column>) => void;
onDelete: () => Promise<unknown>;
}) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: column.id,
});
const { toast } = useToast();
const deleteRequest = useRequest(onDelete);
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleNameChange = (value: string) => {
onUpdate({ name: value });
};
const handleColorChange = (value: string) => {
onUpdate({ color: value });
};
const handleDelete = async () => {
try {
await deleteRequest.run();
} catch (error) {
toast({
title: 'Failed to delete column',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
return (
<div ref={setNodeRef} style={style} className="flex gap-2 items-start">
<button
type="button"
className="mt-2 p-1 rounded-md text-muted-foreground hover:text-foreground cursor-grab"
{...attributes}
{...listeners}
>
<IconGripVertical className="h-4 w-4" />
</button>
<div className="flex-1 grid grid-cols-[1fr_1fr_auto] gap-2 items-start">
<div className="space-y-1">
<Input
placeholder="Column title"
value={column.name}
onChange={(e) => handleNameChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<select
value={column.color}
onChange={(e) => handleColorChange(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{COLOR_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={deleteRequest.isLoading}
className="mt-1"
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
</div>
);
}
export function BoardCard({
board,
columns,
isBoardDirty,
areColumnsDirty,
onUpdateBoard,
onDeleteBoard,
onCreateColumn,
onUpdateColumn,
onDeleteColumn,
onReorderColumns,
onSaveBoard,
}: BoardCardProps) {
const columnItems = useMemo(() => columns.map((column) => column.id), [columns]);
const { toast } = useToast();
const [deleteOpen, setDeleteOpen] = useState(false);
const isMounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
const saveBoardRequest = useRequest(onSaveBoard);
const deleteBoardRequest = useRequest(onDeleteBoard);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = columns.findIndex((column) => column.id === active.id);
const newIndex = columns.findIndex((column) => column.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const nextColumns = arrayMove(columns, oldIndex, newIndex).map((column, index) => ({
...column,
position: index,
}));
onReorderColumns(nextColumns);
};
const handleAddColumn = async () => {
const lastColor = columns[columns.length - 1]?.color ?? COLOR_OPTIONS[0].value;
const lastIndex = COLOR_OPTIONS.findIndex((option) => option.value === lastColor);
const nextColor =
lastIndex >= 0 ? COLOR_OPTIONS[(lastIndex + 1) % COLOR_OPTIONS.length].value : COLOR_OPTIONS[0].value;
onCreateColumn({
name: '',
position: columns.length,
state: 'TODO',
color: nextColor,
});
};
const handleDeleteBoard = async () => {
try {
await deleteBoardRequest.run();
setDeleteOpen(false);
} catch (error) {
toast({
title: 'Failed to delete board',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleSaveBoard = async () => {
try {
await saveBoardRequest.run();
} catch (error) {
toast({
title: 'Failed to save board changes',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
return (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2 flex-1">
<Label className="flex items-center gap-2">
<span>Board Name</span>
{isBoardDirty && <UnsavedChangesBadge />}
</Label>
<div className="flex items-center gap-2">
<Input
value={board.name}
onChange={(e) => onUpdateBoard({ name: e.target.value })}
/>
<UnsavedSaveButton
isDirty={isBoardDirty || areColumnsDirty}
isLoading={saveBoardRequest.isLoading}
status={saveBoardRequest.status}
onClick={handleSaveBoard}
/>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="flex items-center gap-2">
<span>Columns</span>
{areColumnsDirty && <UnsavedChangesBadge />}
</Label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddColumn}
>
<IconPlus className="h-4 w-4 mr-1" />
Add Column
</Button>
</div>
</div>
<div className="space-y-2">
{isMounted ? (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={columnItems}>
{columns.map((column) => (
<SortableColumnRow
key={column.id}
column={column}
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
onDelete={() => onDeleteColumn(column.id)}
/>
))}
</SortableContext>
</DndContext>
) : (
columns.map((column) => (
<SortableColumnRow
key={column.id}
column={column}
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
onDelete={() => onDeleteColumn(column.id)}
/>
))
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleteBoardRequest.isLoading}
>
<IconTrash className="h-4 w-4 mr-2" />
Delete Board
</Button>
</div>
</div>
</CardContent>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete board</DialogTitle>
<DialogDescription>
This will remove the board and its columns. Tasks will not be deleted. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={handleDeleteBoard}>
Delete Board
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -1,214 +0,0 @@
'use client';
import { useState } from 'react';
import { IconFolder, IconTrash, IconEdit, IconCheck, IconX, IconPlus } from '@tabler/icons-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import type { Context } from '@/lib/settings/types';
type ContextCardProps = {
context: Context;
onUpdate: (context: Context) => void;
onDelete: () => void;
};
const COLOR_OPTIONS = [
{ value: 'bg-neutral-400', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-violet-500', label: 'Violet' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-sky-500', label: 'Sky' },
];
export function ContextCard({ context, onUpdate, onDelete }: ContextCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editedContext, setEditedContext] = useState<Context>(context);
const handleSave = () => {
onUpdate(editedContext);
setIsEditing(false);
};
const handleCancel = () => {
setEditedContext(context);
setIsEditing(false);
};
const handleAddColumn = () => {
setEditedContext({
...editedContext,
columns: [
...editedContext.columns,
{ id: crypto.randomUUID(), title: '', color: 'bg-neutral-400' },
],
});
};
const handleRemoveColumn = (id: string) => {
setEditedContext({
...editedContext,
columns: editedContext.columns.filter((c) => c.id !== id),
});
};
const handleUpdateColumn = (id: string, field: 'title' | 'color' | 'id', value: string) => {
setEditedContext({
...editedContext,
columns: editedContext.columns.map((c) =>
c.id === id ? { ...c, [field]: value } : c
),
});
};
if (isEditing) {
return (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>Context Name</Label>
<Input
value={editedContext.name}
onChange={(e) => setEditedContext({ ...editedContext, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Kanban Columns</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddColumn}>
<IconPlus className="h-4 w-4 mr-1" />
Add Column
</Button>
</div>
<div className="space-y-2">
{editedContext.columns.map((column) => (
<div key={column.id} className="flex gap-2 items-start">
<div className="flex-1 grid grid-cols-2 gap-2">
<div className="space-y-1">
<Input
placeholder="Column title"
value={column.title}
onChange={(e) =>
handleUpdateColumn(column.id, 'title', e.target.value)
}
/>
</div>
<div className="space-y-1">
<select
value={column.color}
onChange={(e) =>
handleUpdateColumn(column.id, 'color', e.target.value)
}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{COLOR_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-span-2 space-y-1">
<Input
placeholder="Column ID (e.g., todo, in-progress)"
value={column.id}
onChange={(e) =>
handleUpdateColumn(column.id, 'id', e.target.value)
}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveColumn(column.id)}
>
<IconX className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}>
<IconX className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={handleSave}>
<IconCheck className="h-4 w-4 mr-2" />
Save
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="hover:bg-accent transition-colors cursor-pointer" onClick={() => setIsEditing(true)}>
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconFolder className="h-4 w-4" />
</div>
<div className="flex-1">
<h4 className="font-medium">{context.name}</h4>
<div className="flex flex-wrap gap-1 mt-2">
{context.columns.map((column) => {
// Map Tailwind bg colors to actual hex values
const colorMap: Record<string, string> = {
'bg-neutral-400': '#a3a3a3',
'bg-red-500': '#ef4444',
'bg-orange-500': '#f97316',
'bg-yellow-500': '#eab308',
'bg-green-500': '#22c55e',
'bg-blue-500': '#3b82f6',
'bg-indigo-500': '#6366f1',
'bg-violet-500': '#8b5cf6',
'bg-cyan-500': '#06b6d4',
'bg-sky-500': '#0ea5e9',
};
return (
<Badge
key={column.id}
variant="outline"
className="text-xs"
style={{
borderLeftWidth: '3px',
borderLeftColor: colorMap[column.color] || '#a3a3a3',
}}
>
{column.title}
</Badge>
);
})}
</div>
</div>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={() => setIsEditing(true)}>
<IconEdit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onDelete}>
<IconTrash className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -29,10 +29,11 @@ import {
} from '@/components/ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import { useAppStore } from '@/components/state-provider';
export function SettingsAppSidebar() {
const pathname = usePathname();
const [workspaces] = React.useState(SETTINGS_DATA.workspaces);
const workspaces = useAppStore((state) => state.workspaces.items);
const [environments] = React.useState(SETTINGS_DATA.environments);
const [agents] = React.useState(SETTINGS_DATA.agents);
@@ -0,0 +1,38 @@
'use client';
import { IconArrowLeft } from '@tabler/icons-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from '@/components/ui/breadcrumb';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<SettingsAppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/" className="flex items-center gap-2">
<IconArrowLeft className="h-4 w-4" />
Home
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
{children}
</div>
</SidebarInset>
</SidebarProvider>
);
}
@@ -0,0 +1,37 @@
'use client';
import { IconAlertCircle } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { RequestIndicator } from '@/components/request-indicator';
type UnsavedSaveButtonProps = {
isDirty: boolean;
isLoading: boolean;
status: 'idle' | 'loading' | 'success' | 'error';
onClick: () => void;
};
const warningButtonClass = 'border-yellow-500/60 text-yellow-500 hover:bg-yellow-500/10';
export function UnsavedChangesBadge() {
return <span className="text-xs text-yellow-500">Unsaved changes</span>;
}
export function UnsavedSaveButton({ isDirty, isLoading, status, onClick }: UnsavedSaveButtonProps) {
return (
<Button
type="button"
size="sm"
variant={isDirty ? 'secondary' : 'outline'}
onClick={onClick}
disabled={isLoading}
className={isDirty ? warningButtonClass : undefined}
>
{isDirty && <IconAlertCircle className="h-4 w-4 mr-2" />}
Save
<span className="ml-2">
<RequestIndicator status={status} />
</span>
</Button>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
type ToastVariant = 'default' | 'error';
type Toast = {
id: string;
title?: string;
description?: string;
variant?: ToastVariant;
};
type ToastInput = Omit<Toast, 'id'> & { duration?: number };
type ToastContextValue = {
toast: (input: ToastInput) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const toast = useCallback(
(input: ToastInput) => {
const id = crypto.randomUUID();
const nextToast: Toast = {
id,
title: input.title,
description: input.description,
variant: input.variant ?? 'default',
};
setToasts((prev) => [nextToast, ...prev]);
const duration = input.duration ?? 3000;
window.setTimeout(() => removeToast(id), duration);
},
[removeToast]
);
const value = useMemo(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="fixed right-4 top-4 z-50 flex w-[320px] flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
'rounded-md border border-border/60 bg-background px-3 py-2 shadow-md',
toast.variant === 'error' && 'border-destructive/50 bg-destructive/10'
)}
>
{toast.title && <div className="text-sm font-medium">{toast.title}</div>}
{toast.description && (
<div className="text-xs text-muted-foreground">{toast.description}</div>
)}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
export type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
export type RequestState<T> = {
status: RequestStatus;
data: T | null;
error: Error | null;
};
type UseRequestOptions = {
successDuration?: number;
};
export function useRequest<TArgs extends unknown[], TData>(
fn: (...args: TArgs) => Promise<TData>,
options: UseRequestOptions = {}
) {
const { successDuration = 1500 } = options;
const [state, setState] = useState<RequestState<TData>>({
status: 'idle',
data: null,
error: null,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const run = useCallback(
async (...args: TArgs) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setState({ status: 'loading', data: null, error: null });
try {
const data = await fn(...args);
setState({ status: 'success', data, error: null });
if (successDuration > 0) {
timeoutRef.current = setTimeout(() => {
setState((prev) => ({ ...prev, status: 'idle' }));
}, successDuration);
}
return data;
} catch (error) {
const normalized = error instanceof Error ? error : new Error('Request failed');
setState({ status: 'error', data: null, error: normalized });
throw normalized;
}
},
[fn, successDuration]
);
return {
...state,
isLoading: state.status === 'loading',
run,
reset: () => setState({ status: 'idle', data: null, error: null }),
};
}
+6 -1
View File
@@ -13,7 +13,12 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
return {
kanban: {
boardId: snapshot.board.id,
columns: snapshot.columns.map((column) => ({ id: column.id, title: column.name })),
columns: snapshot.columns.map((column) => ({
id: column.id,
title: column.name,
color: column.color ?? 'bg-neutral-400',
position: column.position,
})),
tasks,
},
};
+23 -1
View File
@@ -4,7 +4,7 @@ import { immer } from 'zustand/middleware/immer';
export type KanbanState = {
boardId: string | null;
columns: Array<{ id: string; title: string }>;
columns: Array<{ id: string; title: string; color: string; position: number }>;
tasks: Array<{ id: string; columnId: string; title: string }>;
};
@@ -13,6 +13,11 @@ export type WorkspaceState = {
activeId: string | null;
};
export type BoardState = {
items: Array<{ id: string; workspaceId: string; name: string }>;
activeId: string | null;
};
export type TaskState = {
activeTaskId: string | null;
};
@@ -37,6 +42,7 @@ export type ConnectionState = {
export type AppState = {
kanban: KanbanState;
workspaces: WorkspaceState;
boards: BoardState;
tasks: TaskState;
agents: AgentState;
terminal: TerminalState;
@@ -45,6 +51,8 @@ export type AppState = {
hydrate: (state: Partial<AppState>) => void;
setActiveWorkspace: (workspaceId: string | null) => void;
setWorkspaces: (workspaces: WorkspaceState['items']) => void;
setActiveBoard: (boardId: string | null) => void;
setBoards: (boards: BoardState['items']) => void;
setTerminalOutput: (terminalId: string, data: string) => void;
setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void;
};
@@ -54,6 +62,7 @@ export type AppStore = ReturnType<typeof createAppStore>;
const defaultState: AppState = {
kanban: { boardId: null, columns: [], tasks: [] },
workspaces: { items: [], activeId: null },
boards: { items: [], activeId: null },
tasks: { activeTaskId: null },
agents: { agents: [] },
terminal: { terminals: [] },
@@ -62,6 +71,8 @@ const defaultState: AppState = {
hydrate: () => undefined,
setActiveWorkspace: () => undefined,
setWorkspaces: () => undefined,
setActiveBoard: () => undefined,
setBoards: () => undefined,
setTerminalOutput: () => undefined,
setConnectionStatus: () => undefined,
};
@@ -86,6 +97,17 @@ export function createAppStore(initialState?: Partial<AppState>) {
draft.workspaces.activeId = workspaces[0].id;
}
}),
setBoards: (boards) =>
set((draft) => {
draft.boards.items = boards;
if (!draft.boards.activeId && boards.length) {
draft.boards.activeId = boards[0].id;
}
}),
setActiveBoard: (boardId) =>
set((draft) => {
draft.boards.activeId = boardId;
}),
setTerminalOutput: (terminalId, data) =>
set((draft) => {
const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId);
+56 -8
View File
@@ -1,21 +1,31 @@
export type BackendMessageType =
| 'kanban.update'
| 'task.update'
| 'agent.update'
| 'task.updated'
| 'agent.updated'
| 'terminal.output'
| 'diff.update'
| 'system.error';
| 'system.error'
| 'workspace.created'
| 'workspace.updated'
| 'workspace.deleted'
| 'board.created'
| 'board.updated'
| 'board.deleted'
| 'column.created'
| 'column.updated'
| 'column.deleted';
export type BackendMessage<T extends BackendMessageType, P> = {
type: T;
id?: string;
type: 'request' | 'response' | 'notification' | 'error';
action: T;
payload: P;
requestId?: string;
timestamp?: string;
};
export type KanbanUpdatePayload = {
boardId: string;
columns: Array<{ id: string; title: string }>;
columns: Array<{ id: string; title: string; color?: string; position?: number }>;
tasks: Array<{ id: string; columnId: string; title: string }>;
};
@@ -53,11 +63,49 @@ export type SystemErrorPayload = {
code?: string;
};
export type WorkspacePayload = {
id: string;
name: string;
description?: string;
owner_id?: string;
created_at?: string;
updated_at?: string;
};
export type BoardPayload = {
id: string;
workspace_id: string;
name: string;
description?: string;
created_at?: string;
updated_at?: string;
};
export type ColumnPayload = {
id: string;
board_id: string;
name: string;
position: number;
state: string;
color: string;
created_at?: string;
updated_at?: string;
};
export type BackendMessageMap = {
'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>;
'task.update': BackendMessage<'task.update', TaskUpdatePayload>;
'agent.update': BackendMessage<'agent.update', AgentUpdatePayload>;
'task.updated': BackendMessage<'task.updated', TaskUpdatePayload>;
'agent.updated': BackendMessage<'agent.updated', AgentUpdatePayload>;
'terminal.output': BackendMessage<'terminal.output', TerminalOutputPayload>;
'diff.update': BackendMessage<'diff.update', DiffUpdatePayload>;
'system.error': BackendMessage<'system.error', SystemErrorPayload>;
'workspace.created': BackendMessage<'workspace.created', WorkspacePayload>;
'workspace.updated': BackendMessage<'workspace.updated', WorkspacePayload>;
'workspace.deleted': BackendMessage<'workspace.deleted', WorkspacePayload>;
'board.created': BackendMessage<'board.created', BoardPayload>;
'board.updated': BackendMessage<'board.updated', BoardPayload>;
'board.deleted': BackendMessage<'board.deleted', BoardPayload>;
'column.created': BackendMessage<'column.created', ColumnPayload>;
'column.updated': BackendMessage<'column.updated', ColumnPayload>;
'column.deleted': BackendMessage<'column.deleted', ColumnPayload>;
};
+6
View File
@@ -31,6 +31,7 @@ export type Column = {
name: string;
position: number;
state: TaskState;
color: string;
created_at: string;
updated_at: string;
};
@@ -65,6 +66,11 @@ export type ListBoardsResponse = {
total: number;
};
export type ListColumnsResponse = {
columns: Column[];
total: number;
};
export type ListWorkspacesResponse = {
workspaces: Workspace[];
total: number;
+45 -1
View File
@@ -8,6 +8,10 @@ export class WebSocketClient {
private socket: WebSocket | null = null;
private status: WebSocketStatus = 'idle';
private handlers = new Map<BackendMessageType, Set<MessageHandler<BackendMessageType>>>();
private pendingRequests = new Map<
string,
{ resolve: (payload: unknown) => void; reject: (error: Error) => void; timeout: ReturnType<typeof setTimeout> }
>();
private pendingQueue: string[] = [];
constructor(
@@ -32,7 +36,35 @@ export class WebSocketClient {
this.socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as BackendMessageMap[BackendMessageType];
const handlers = this.handlers.get(message.type);
if (message.type === 'response' && message.id) {
const pending = this.pendingRequests.get(message.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.id);
pending.resolve(message.payload);
}
return;
}
if (message.type === 'error' && message.id) {
const pending = this.pendingRequests.get(message.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.id);
const errorMessage =
typeof message.payload === 'object' && message.payload && 'message' in message.payload
? String((message.payload as { message?: string }).message)
: 'WebSocket request failed';
pending.reject(new Error(errorMessage));
}
return;
}
if (message.type !== 'notification') {
return;
}
const action = message?.action;
if (!action) return;
const handlers = this.handlers.get(action);
if (!handlers) return;
handlers.forEach((handler) => handler(message));
} catch {
@@ -66,6 +98,18 @@ export class WebSocketClient {
this.socket.send(data);
}
request<T>(action: string, payload: unknown, timeoutMs = 5000): Promise<T> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`WebSocket request timed out: ${action}`));
}, timeoutMs);
this.pendingRequests.set(id, { resolve, reject, timeout });
this.send({ id, type: 'request', action, payload });
});
}
on<T extends BackendMessageType>(type: T, handler: MessageHandler<T>) {
const handlers = this.handlers.get(type) ?? new Set();
handlers.add(handler as MessageHandler<BackendMessageType>);
+11
View File
@@ -0,0 +1,11 @@
import type { WebSocketClient } from '@/lib/ws/client';
let activeClient: WebSocketClient | null = null;
export function setWebSocketClient(client: WebSocketClient | null) {
activeClient = client;
}
export function getWebSocketClient() {
return activeClient;
}
+182 -3
View File
@@ -9,12 +9,17 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
...state,
kanban: {
boardId: message.payload.boardId,
columns: message.payload.columns,
columns: message.payload.columns.map((column, index) => ({
id: column.id,
title: column.title,
color: column.color ?? 'bg-neutral-400',
position: column.position ?? index,
})),
tasks: message.payload.tasks,
},
}));
},
'task.update': (message: BackendMessageMap['task.update']) => {
'task.updated': (message: BackendMessageMap['task.updated']) => {
store.setState((state) => ({
...state,
tasks: {
@@ -23,7 +28,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
},
}));
},
'agent.update': (message: BackendMessageMap['agent.update']) => {
'agent.updated': (message: BackendMessageMap['agent.updated']) => {
store.setState((state) => ({
...state,
agents: {
@@ -48,6 +53,180 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
},
}));
},
'workspace.created': (message: BackendMessageMap['workspace.created']) => {
store.setState((state) => {
const exists = state.workspaces.items.some((item) => item.id === message.payload.id);
const items = exists
? state.workspaces.items.map((item) =>
item.id === message.payload.id ? { ...item, name: message.payload.name } : item
)
: [{ id: message.payload.id, name: message.payload.name }, ...state.workspaces.items];
const activeId = state.workspaces.activeId ?? message.payload.id;
return {
...state,
workspaces: {
items,
activeId,
},
};
});
},
'workspace.updated': (message: BackendMessageMap['workspace.updated']) => {
store.setState((state) => ({
...state,
workspaces: {
...state.workspaces,
items: state.workspaces.items.map((item) =>
item.id === message.payload.id ? { ...item, name: message.payload.name } : item
),
},
}));
},
'workspace.deleted': (message: BackendMessageMap['workspace.deleted']) => {
store.setState((state) => {
const items = state.workspaces.items.filter((item) => item.id !== message.payload.id);
const activeId =
state.workspaces.activeId === message.payload.id ? items[0]?.id ?? null : state.workspaces.activeId;
const clearBoards = state.workspaces.activeId === message.payload.id;
return {
...state,
workspaces: {
items,
activeId,
},
boards: clearBoards ? { items: [], activeId: null } : state.boards,
kanban: clearBoards ? { boardId: null, columns: [], tasks: [] } : state.kanban,
};
});
},
'board.created': (message: BackendMessageMap['board.created']) => {
store.setState((state) => {
if (state.workspaces.activeId !== message.payload.workspace_id) {
return state;
}
const exists = state.boards.items.some((item) => item.id === message.payload.id);
if (exists) {
return state;
}
return {
...state,
boards: {
items: [
{
id: message.payload.id,
workspaceId: message.payload.workspace_id,
name: message.payload.name,
},
...state.boards.items,
],
activeId: state.boards.activeId ?? message.payload.id,
},
};
});
},
'board.updated': (message: BackendMessageMap['board.updated']) => {
store.setState((state) => ({
...state,
boards: {
...state.boards,
items: state.boards.items.map((item) =>
item.id === message.payload.id ? { ...item, name: message.payload.name } : item
),
},
}));
},
'board.deleted': (message: BackendMessageMap['board.deleted']) => {
store.setState((state) => {
const items = state.boards.items.filter((item) => item.id !== message.payload.id);
const nextActiveId =
state.boards.activeId === message.payload.id ? items[0]?.id ?? null : state.boards.activeId;
return {
...state,
boards: {
items,
activeId: nextActiveId,
},
kanban:
state.kanban.boardId === message.payload.id
? { boardId: nextActiveId, columns: [], tasks: [] }
: state.kanban,
};
});
},
'column.created': (message: BackendMessageMap['column.created']) => {
store.setState((state) => {
if (state.kanban.boardId !== message.payload.board_id) {
return state;
}
const nextColumns = [
...state.kanban.columns,
{
id: message.payload.id,
title: message.payload.name,
color: message.payload.color,
position: message.payload.position,
},
].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return {
...state,
kanban: {
...state.kanban,
columns: nextColumns,
},
};
});
},
'column.updated': (message: BackendMessageMap['column.updated']) => {
store.setState((state) => {
if (state.kanban.boardId !== message.payload.board_id) {
return state;
}
const exists = state.kanban.columns.some((column) => column.id === message.payload.id);
const updatedColumns = exists
? state.kanban.columns.map((column) =>
column.id === message.payload.id
? {
...column,
title: message.payload.name,
color: message.payload.color,
position: message.payload.position,
}
: column
)
: [
...state.kanban.columns,
{
id: message.payload.id,
title: message.payload.name,
color: message.payload.color,
position: message.payload.position,
},
];
const nextColumns = updatedColumns.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return {
...state,
kanban: {
...state.kanban,
columns: nextColumns,
},
};
});
},
'column.deleted': (message: BackendMessageMap['column.deleted']) => {
store.setState((state) => {
if (state.kanban.boardId !== message.payload.board_id) {
return state;
}
const nextColumns = state.kanban.columns.filter((column) => column.id !== message.payload.id);
return {
...state,
kanban: {
...state.kanban,
columns: nextColumns,
},
};
});
},
'system.error': () => {
// TODO: surface as toast/notification once UI is ready.
},
+3
View File
@@ -5,6 +5,7 @@ import type { StoreApi } from 'zustand';
import { WebSocketClient } from '@/lib/ws/client';
import { registerWsHandlers } from '@/lib/ws/router';
import type { AppState } from '@/lib/state/store';
import { setWebSocketClient } from '@/lib/ws/connection';
export function useWebSocket(store: StoreApi<AppState>, url: string) {
const clientRef = useRef<WebSocketClient | null>(null);
@@ -31,6 +32,7 @@ export function useWebSocket(store: StoreApi<AppState>, url: string) {
});
clientRef.current = client;
client.connect();
setWebSocketClient(client);
const handlers = registerWsHandlers(store);
const unsubscribers = Object.entries(handlers).map(([type, handler]) =>
@@ -40,6 +42,7 @@ export function useWebSocket(store: StoreApi<AppState>, url: string) {
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
client.disconnect();
setWebSocketClient(null);
};
}, [store, url]);