From 81a22a3f553a364bb02a4aaed1f73f3f6fa5b697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 27 Aug 2026 16:33:46 +0000 Subject: [PATCH] feat: improve quick chat tabs (#3076) --- .agents/skills/pr-fixup/SKILL.md | 18 + .../internal/backendapp/boot_state_routes.go | 1 + .../internal/backendapp/helpers_test.go | 18 +- .../task/handlers/message_handlers.go | 139 +++-- .../message_handlers_quick_chat_title_test.go | 312 ++++++++++ .../task/handlers/task_http_handlers.go | 2 + .../task/handlers/task_http_handlers_test.go | 55 +- apps/backend/internal/task/models/models.go | 6 +- .../task/service/quick_chat_sessions.go | 20 +- .../task/service/quick_chat_sessions_test.go | 22 +- .../internal/task/service/service_tasks.go | 33 +- .../task/service/service_tasks_test.go | 61 ++ .../internal/task/service/service_test.go | 9 +- .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/models/models.go | 1 + apps/backend/internal/user/service/service.go | 46 +- .../internal/user/service/service_test.go | 55 ++ .../user/store/quick_chat_tab_order_test.go | 60 ++ apps/backend/internal/user/store/sqlite.go | 22 + .../quick-chat/quick-chat-dialog.tsx | 3 + .../quick-chat/quick-chat-modal.test.tsx | 34 ++ .../quick-chat/quick-chat-modal.tsx | 123 +--- .../quick-chat/quick-chat-tab-item.test.tsx | 153 ++++- .../quick-chat/quick-chat-tab-item.tsx | 551 ++++++++++++++---- .../quick-chat/quick-chat-tab-strip.tsx | 412 +++++++++++++ .../quick-chat/quick-terminal-tab-item.tsx | 97 ++- .../use-quick-chat-close-actions.ts | 249 ++++++++ .../quick-chat/use-quick-chat-modal.test.ts | 75 +++ .../quick-chat/use-quick-chat-modal.ts | 149 ++--- .../use-quick-chat-tab-order.test.ts | 200 +++++++ .../quick-chat/use-quick-chat-tab-order.ts | 128 ++++ .../tests/chat/mobile-quick-chat-tabs.spec.ts | 123 ++++ apps/web/e2e/tests/chat/quick-chat-helpers.ts | 10 + apps/web/e2e/tests/chat/quick-chat.spec.ts | 190 +++++- .../terminal/mobile-quick-terminal.spec.ts | 34 +- .../e2e/tests/terminal/quick-terminal.spec.ts | 8 +- .../tests/terminal/terminal-test-helpers.ts | 21 +- .../hooks/use-ensure-user-settings.test.ts | 1 + apps/web/lib/api/domains/workspace-api.ts | 1 + apps/web/lib/ssr/user-settings.test.ts | 17 + apps/web/lib/ssr/user-settings.ts | 3 + apps/web/lib/state/slices/settings/types.ts | 1 + .../lib/state/slices/ui/quick-chat-actions.ts | 30 + .../state/slices/ui/quick-chat-sync.test.ts | 3 + .../slices/ui/quick-chat-tab-order.test.ts | 67 +++ .../state/slices/ui/quick-chat-tab-order.ts | 91 +++ apps/web/lib/state/slices/ui/types.ts | 13 + apps/web/lib/state/slices/ui/ui-slice.ts | 3 + apps/web/lib/state/store.ts | 3 + apps/web/lib/types/http-user-settings.ts | 2 + apps/web/src/locales/en/chat.json | 6 + apps/web/src/locales/pseudo/chat.json | 6 + apps/web/src/locales/pt-pt/chat.json | 6 + apps/web/src/locales/zh-cn/chat.json | 6 + apps/web/src/locales/zh-hk/chat.json | 6 + apps/web/src/locales/zh-tw/chat.json | 6 + .../2026-08-26-quick-chat-agent-titles.md | 58 ++ .../2026-08-26-quick-chat-tab-order.md | 70 +++ docs/decisions/INDEX.md | 2 + docs/plans/quick-chat-tab-order/plan.md | 123 ++++ .../task-01-persist-tab-order.md | 86 +++ .../task-02-add-sortable-tab-strip.md | 83 +++ .../task-03-improve-tab-editing.md | 80 +++ .../task-04-prove-tab-order-e2e.md | 95 +++ .../task-05-enable-agent-titles.md | 99 ++++ docs/public/automation-and-mcp.md | 10 +- docs/public/developer-tools.md | 7 + docs/specs/tasks/README.md | 2 + .../requirements/agent-generated-titles.md | 2 +- .../requirements/quick-chat-agent-titles.md | 47 ++ .../system-design/quick-chat-agent-titles.md | 123 ++++ docs/specs/ui/requirements/quick-terminal.md | 21 +- docs/specs/ui/system-design/quick-terminal.md | 154 ++++- 74 files changed, 4275 insertions(+), 502 deletions(-) create mode 100644 apps/backend/internal/task/handlers/message_handlers_quick_chat_title_test.go create mode 100644 apps/backend/internal/user/store/quick_chat_tab_order_test.go create mode 100644 apps/web/components/quick-chat/quick-chat-tab-strip.tsx create mode 100644 apps/web/components/quick-chat/use-quick-chat-close-actions.ts create mode 100644 apps/web/components/quick-chat/use-quick-chat-tab-order.test.ts create mode 100644 apps/web/components/quick-chat/use-quick-chat-tab-order.ts create mode 100644 apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts create mode 100644 apps/web/lib/state/slices/ui/quick-chat-tab-order.test.ts create mode 100644 apps/web/lib/state/slices/ui/quick-chat-tab-order.ts create mode 100644 docs/decisions/2026-08-26-quick-chat-agent-titles.md create mode 100644 docs/decisions/2026-08-26-quick-chat-tab-order.md create mode 100644 docs/plans/quick-chat-tab-order/plan.md create mode 100644 docs/plans/quick-chat-tab-order/task-01-persist-tab-order.md create mode 100644 docs/plans/quick-chat-tab-order/task-02-add-sortable-tab-strip.md create mode 100644 docs/plans/quick-chat-tab-order/task-03-improve-tab-editing.md create mode 100644 docs/plans/quick-chat-tab-order/task-04-prove-tab-order-e2e.md create mode 100644 docs/plans/quick-chat-tab-order/task-05-enable-agent-titles.md create mode 100644 docs/specs/tasks/requirements/quick-chat-agent-titles.md create mode 100644 docs/specs/tasks/system-design/quick-chat-agent-titles.md diff --git a/.agents/skills/pr-fixup/SKILL.md b/.agents/skills/pr-fixup/SKILL.md index 32e32c5479..11342283c6 100644 --- a/.agents/skills/pr-fixup/SKILL.md +++ b/.agents/skills/pr-fixup/SKILL.md @@ -148,6 +148,17 @@ For unfamiliar, infrastructure, or E2E failures, load Also load it for unexpected zero-duration or no-op manual-review runs: event and workflow provenance can explain them without a product-code change. +An explicit request to run `pr fixup` owns every failed required check on the +current head. Never stop by calling a failure "unrelated" only because its +files are outside the PR diff. Reproduce each leaf failure with retries +disabled, inspect its artifacts and shared fixtures/cleanup, and fix every +valid product, test, fixture, cleanup, or CI-contract defect it exposes. A +dependent aggregate failure does not replace its leaf failures: trace the +aggregate to all failed jobs, fix the underlying failures, and wait for the +aggregate checks to rerun. If concrete evidence proves a failure is external +after this investigation, report the exact job/log/reproduction evidence and +keep the PR blocked; do not call it ready while a required check is failed. + Fix with `/tdd` or `/e2e` as applicable, run focused checks, and keep each remediation scoped to the reported failure. Do not suppress a failure or mark a check clean without fresh evidence. @@ -300,6 +311,13 @@ local `HEAD`, and the fresh mergeability state is not conflicting. Do not call the PR clean from CI/review counts alone when the worktree or remote tip still differs. +The phrase "ready to merge" is reserved for a fresh current-head snapshot +with every required check successful or explicitly skipped, no pending or +failed leaf or aggregate check, no blocking review/thread, no merge conflict, +and the local, upstream, and PR head OIDs aligned. A clean local reproduction +does not waive a failed remote check; push the remediation and re-check the +new head first. + ## 6. User-Requested Merge Merge only after the user explicitly asks and the current-head state is clean. diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 2fe22ee440..7792ac8229 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -668,6 +668,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str }, "appStatusBarEnabled": settings.AppStatusBarEnabled, "appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder), + "quickChatTabOrderByWorkspace": settings.QuickChatTabOrderByWorkspace, "hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs), "workflowIdsWithAutoHideEmptySteps": stringSlice(settings.WorkflowIDsWithAutoHideEmptySteps), "loaded": true, diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 8e4ce792ee..81e2867845 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -1529,16 +1529,16 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { if len(sessions) != 3 { t.Fatalf("quickChat sessions = %#v, want 3 restored sessions", sessions) } - if got := sessions[0].SessionID; got != "task-config-session" { - t.Fatalf("first restored session = %q, want newest task-config-session", got) + if got := sessions[0].SessionID; got != "task-old-session" { + t.Fatalf("first restored session = %q, want oldest task-old-session", got) } - if sessions[0].Kind != "config" || sessions[1].Kind != "chat" || sessions[2].Kind != "chat" { - t.Fatalf("restored session kinds = %#v, want config, chat, chat", sessions) + if sessions[0].Kind != "chat" || sessions[1].Kind != "chat" || sessions[2].Kind != "config" { + t.Fatalf("restored session kinds = %#v, want two chat sessions and one config session", sessions) } - if sessions[0].WorkspaceID != "ws-qc" || sessions[0].Name != "Config" { - t.Fatalf("config session identity = %#v, want workspace and task title preserved", sessions[0]) + if sessions[2].WorkspaceID != "ws-qc" || sessions[2].Name != "Config" { + t.Fatalf("config session identity = %#v, want workspace and task title preserved", sessions[2]) } - if sessions[0].AgentProfileID != "agent-config" || sessions[1].AgentProfileID != "agent-new" || sessions[2].AgentProfileID != "agent-old" { + if sessions[0].AgentProfileID != "agent-old" || sessions[1].AgentProfileID != "agent-new" || sessions[2].AgentProfileID != "agent-config" { t.Fatalf("agent profile IDs = %#v", sessions) } if got := decoded.InitialState.TaskSessions.Items["task-config-session"].TaskID; got != "task-config" { @@ -1626,8 +1626,8 @@ func TestBootPayloadRestoresQuickChatsFromTaskRouteWorkspace(t *testing.T) { if len(sessions) != 2 { t.Fatalf("quickChat sessions = %#v, want 2 task-workspace sessions", sessions) } - if sessions[0].SessionID != "task-route-second-session" || sessions[1].SessionID != "task-route-first-session" { - t.Fatalf("quickChat sessions = %#v, want task-workspace activity order", sessions) + if sessions[0].SessionID != "task-route-first-session" || sessions[1].SessionID != "task-route-second-session" { + t.Fatalf("quickChat sessions = %#v, want task-workspace creation order", sessions) } for _, session := range sessions { if session.WorkspaceID != "ws-task" { diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index add38ec0fd..104ada705f 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -106,6 +106,78 @@ func (h *MessageHandlers) claimTaskTitleSession(ctx context.Context, task *model return claimer.ClaimTaskTitleSession(ctx, taskID, sessionID) } +func (h *MessageHandlers) resolveMessageTaskAndTitleOwner( + ctx context.Context, + msg *ws.Message, + task *models.Task, + taskID string, + sessionID string, + configMode bool, + startCreatedSession bool, + hasMessageContent bool, +) (*models.Task, bool, *ws.Message) { + if !startCreatedSession && (configMode || !hasMessageContent) { + return task, false, nil + } + if configMode { + return task, false, nil + } + if startCreatedSession { + titleOwner, claimErr := h.claimTaskTitleSession(ctx, task, taskID, sessionID) + if claimErr != nil { + h.logger.Error("failed to claim first-turn task title", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(claimErr)) + wsErr, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to claim task title", nil) + return nil, false, wsErr + } + return task, titleOwner, nil + } + if !service.IsRestorableQuickChatTask(task) { + return task, false, nil + } + return task, models.IsAgentTitleOwner(task.Metadata, sessionID), nil +} + +func (h *MessageHandlers) injectMessageContext( + ctx context.Context, + req wsAddMessageRequest, + sessionResp *dto.GetTaskSessionResponse, + task *models.Task, + configMode bool, + startCreatedSession bool, + titleOwner bool, + content string, +) string { + requiresSignal := h.orchestrator != nil && h.orchestrator.StepRequiresCompletionSignal(ctx, req.TaskID) + referenceContext := orchestrator.EntityReferenceContext(req.EntityReferences) + var pullRequestTargetContext string + content, pullRequestTargetContext = sysprompt.InjectPullRequestTargetContext( + content, h.taskPullRequestTargets(ctx, task), + ) + if task.IsFromOffice { + return sysprompt.InjectOfficeContextWithOptions( + req.TaskID, req.TaskSessionID, content, requiresSignal, + referenceContext, pullRequestTargetContext, + ) + } + if sessionResp.Session.IsPassthrough { + if !startCreatedSession && titleOwner { + return sysprompt.PendingTaskTitlePassthroughInstruction() + "\n\n" + content + } + return content + } + return sysprompt.InjectKandevContextWithOptions(req.TaskID, req.TaskSessionID, content, sysprompt.KandevContextOptions{ + RequiresCompletionSignal: requiresSignal, + IncludeCoordinatorTaskControls: !configMode, + IncludeTaskTitleTool: !configMode && titleOwner, + Autopilot: task.Autopilot, + IncludeUserQuestionTool: !task.Autopilot && !sessionResp.Session.IsPassthrough, + IncludeParentQuestionTool: task.Autopilot && task.ParentID != "", + }, referenceContext, pullRequestTargetContext) +} + // RegisterMessageRoutes registers message HTTP + WebSocket handlers func RegisterMessageRoutes( router *gin.Engine, @@ -359,7 +431,8 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } // Transition task from REVIEW → IN_PROGRESS if needed - if err := h.ensureTaskInProgress(ctx, req.TaskID); err != nil { + task, err := h.ensureTaskInProgress(ctx, req.TaskID) + if err != nil { h.logger.Error("failed to get task", zap.String("task_id", req.TaskID), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task", nil) } @@ -415,8 +488,8 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w WithContextFiles(req.ContextFiles). WithEntityReferences(req.EntityReferences) - // First message on a CREATED session is the kanban "type in chat to start - // the agent" path. Wrap with the Kandev MCP system block before persisting + // The first prompt on a new or eager Quick Chat session is the kanban "type + // in chat to start the agent" path. Wrap with the Kandev MCP system block before persisting // so the DB row matches what the agent receives (and "Show formatted" // reveals it). The orchestrator's wrap in StartCreatedSession is // mode-aware and canonicalizing, so passing the wrapped content through @@ -431,39 +504,23 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // the agent CLI's TTY and the user sees it verbatim — they don't want a // wall of MCP-tool boilerplate prepended to "hello". storedContent := orchestrator.AppendEntityReferenceContext(req.Content, req.EntityReferences) - if startCreatedSession && !sessionResp.Session.IsPassthrough && (req.Content != "" || len(req.Attachments) > 0) { - task, err := h.service.GetTask(ctx, req.TaskID) - if err != nil { - h.logger.Error("failed to resolve first-turn MCP capabilities", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task", nil) - } - configMode, _ := sessionResp.Session.Metadata["config_mode"].(bool) - titleOwner, claimErr := h.claimTaskTitleSession(ctx, task, req.TaskID, req.TaskSessionID) - if claimErr != nil { - h.logger.Error("failed to claim first-turn task title", zap.String("task_id", req.TaskID), zap.String("session_id", req.TaskSessionID), zap.Error(claimErr)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to claim task title", nil) - } - requiresSignal := h.orchestrator != nil && h.orchestrator.StepRequiresCompletionSignal(ctx, req.TaskID) - referenceContext := orchestrator.EntityReferenceContext(req.EntityReferences) - var pullRequestTargetContext string - storedContent, pullRequestTargetContext = sysprompt.InjectPullRequestTargetContext( - storedContent, h.taskPullRequestTargets(ctx, task), + configMode, _ := sessionResp.Session.Metadata["config_mode"].(bool) + titleOwner := false + hasMessageContent := req.Content != "" || len(req.Attachments) > 0 + task, titleOwner, wsErr = h.resolveMessageTaskAndTitleOwner( + ctx, msg, task, req.TaskID, req.TaskSessionID, configMode, startCreatedSession, hasMessageContent, + ) + if wsErr != nil { + return wsErr, nil + } + if (startCreatedSession || titleOwner) && hasMessageContent { + // The first prompt on a new or eager Quick Chat session is the kanban + // "type in chat to start the agent" path. Wrap with the Kandev MCP + // system block before persisting so the DB row matches what the agent + // receives (and "Show formatted" reveals it). + storedContent = h.injectMessageContext( + ctx, req, sessionResp, task, configMode, startCreatedSession, titleOwner, storedContent, ) - if task.IsFromOffice { - storedContent = sysprompt.InjectOfficeContextWithOptions( - req.TaskID, req.TaskSessionID, storedContent, requiresSignal, - referenceContext, pullRequestTargetContext, - ) - } else { - storedContent = sysprompt.InjectKandevContextWithOptions(req.TaskID, req.TaskSessionID, storedContent, sysprompt.KandevContextOptions{ - RequiresCompletionSignal: requiresSignal, - IncludeCoordinatorTaskControls: !configMode, - IncludeTaskTitleTool: !configMode && titleOwner, - Autopilot: task.Autopilot, - IncludeUserQuestionTool: !task.Autopilot && !sessionResp.Session.IsPassthrough, - IncludeParentQuestionTool: task.Autopilot && task.ParentID != "", - }, referenceContext, pullRequestTargetContext) - } } req.Content = storedContent if err := h.service.ClaimMessageAttachments(ctx, req.TaskID, req.TaskSessionID, req.Attachments); err != nil { @@ -479,7 +536,6 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w Metadata: meta.ToMap(), } var message *models.Message - var err error if req.ClientMessageID != "" { message, err = h.service.CreateMessageIdempotent(ctx, req.ClientMessageID, createRequest) } else { @@ -676,14 +732,15 @@ func (h *MessageHandlers) logBlockedRunningSession(sessionID string, state model } // ensureTaskInProgress fetches the task and transitions it from REVIEW → IN_PROGRESS if needed. -// Returns an error only when the task cannot be fetched. -func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID string) error { +// The fetched task is returned so message context injection can reuse the same +// snapshot instead of issuing another repository lookup. +func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID string) (*models.Task, error) { task, err := h.service.GetTask(ctx, taskID) if err != nil { - return err + return nil, err } if task.State != v1.TaskStateReview { - return nil + return task, nil } if _, err := h.service.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil { h.logger.Error("failed to transition task from REVIEW to IN_PROGRESS", @@ -693,7 +750,7 @@ func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID strin h.logger.Info("task transitioned from REVIEW to IN_PROGRESS", zap.String("task_id", taskID)) } - return nil + return task, nil } // dispatchPromptAsync forwards the message to the agent as a prompt in a diff --git a/apps/backend/internal/task/handlers/message_handlers_quick_chat_title_test.go b/apps/backend/internal/task/handlers/message_handlers_quick_chat_title_test.go new file mode 100644 index 0000000000..09ed9e7769 --- /dev/null +++ b/apps/backend/internal/task/handlers/message_handlers_quick_chat_title_test.go @@ -0,0 +1,312 @@ +package handlers + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestWSAddMessagePendingQuickChatOwnerReceivesTitleContext(t *testing.T) { + tests := []struct { + name string + passthrough bool + config bool + owner string + wantTitleTool bool + wantStructured bool + wantPassthrough bool + wantPlainMessage bool + }{ + { + name: "structured owner", + owner: "s1", + wantTitleTool: true, + wantStructured: true, + }, + { + name: "passthrough owner", + passthrough: true, + owner: "s1", + wantTitleTool: true, + wantPassthrough: true, + }, + { + name: "different session", + owner: "s2", + wantPlainMessage: true, + }, + { + name: "configuration session", + config: true, + owner: "s1", + wantPlainMessage: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + "task-quick": { + ID: "task-quick", + WorkspaceID: "ws-quick", + State: v1.TaskStateInProgress, + IsEphemeral: true, + Metadata: map[string]interface{}{ + models.MetaKeyAgentTitlePending: true, + models.MetaKeyAgentTitleOwnerSessionID: tt.owner, + }, + }, + }, + sessions: map[string]*models.TaskSession{ + "s1": { + ID: "s1", + TaskID: "task-quick", + State: models.TaskSessionStateWaitingForInput, + IsPassthrough: tt.passthrough, + Metadata: map[string]interface{}{}, + UpdatedAt: time.Now().UTC(), + }, + }, + primaryID: "s1", + } + if tt.config { + repo.sessions["s1"].Metadata["config_mode"] = true + } + + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, nil, log) + req, err := ws.NewRequest("quick-title-message", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "task-quick", "session_id": "s1", "content": "Organize this chat", + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Len(t, repo.messages, 1) + content := repo.messages[0].Content + + switch { + case tt.wantStructured: + require.Contains(t, content, "Kandev Task ID: task-quick") + require.Contains(t, content, "Session ID: s1") + require.Contains(t, content, "set_task_title_kandev") + case tt.wantPassthrough: + require.Contains(t, content, "Before doing any other work, call set_task_title_kandev") + require.NotContains(t, content, "") + case tt.wantPlainMessage: + require.Equal(t, "Organize this chat", content) + } + + if !tt.wantTitleTool { + require.NotContains(t, content, "set_task_title_kandev") + } + }) + } +} + +func TestWSAddMessagePendingQuickChatOwnerGetsTitleContextAgainWhilePending(t *testing.T) { + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"task-quick": { + ID: "task-quick", + WorkspaceID: "ws-quick", + State: v1.TaskStateInProgress, + IsEphemeral: true, + Metadata: map[string]interface{}{ + models.MetaKeyAgentTitlePending: true, + models.MetaKeyAgentTitleOwnerSessionID: "s1", + }, + }}, + sessions: map[string]*models.TaskSession{"s1": { + ID: "s1", + TaskID: "task-quick", + State: models.TaskSessionStateWaitingForInput, + UpdatedAt: time.Now().UTC(), + }}, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, nil, log) + + for index, prompt := range []string{"First request", "Try again"} { + req, err := ws.NewRequest( + "quick-title-retry-"+string(rune('a'+index)), + ws.ActionMessageAdd, + map[string]interface{}{"task_id": "task-quick", "session_id": "s1", "content": prompt}, + ) + require.NoError(t, err) + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + } + + require.Len(t, repo.messages, 2) + for _, message := range repo.messages { + require.Equal(t, 1, strings.Count(message.Content, "set_task_title_kandev")) + } + require.Equal(t, 2, repo.taskGetCalls, "the task fetched for state transition is reused for title injection") +} + +func TestWSAddMessagePendingQuickChatPassthroughCreatedSessionKeepsFirstPromptVerbatim(t *testing.T) { + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"task-quick": { + ID: "task-quick", + WorkspaceID: "ws-quick", + State: v1.TaskStateInProgress, + IsEphemeral: true, + Metadata: map[string]interface{}{ + models.MetaKeyAgentTitlePending: true, + models.MetaKeyAgentTitleOwnerSessionID: "s1", + }, + }}, + sessions: map[string]*models.TaskSession{"s1": { + ID: "s1", + TaskID: "task-quick", + State: models.TaskSessionStateCreated, + IsPassthrough: true, + AgentProfileID: "profile-1", + UpdatedAt: time.Now().UTC(), + }}, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, nil, log) + + firstReq, err := ws.NewRequest("passthrough-created", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "task-quick", "session_id": "s1", "content": "hello from passthrough", + }) + require.NoError(t, err) + _, err = h.wsAddMessage(context.Background(), firstReq) + require.NoError(t, err) + require.Len(t, repo.messages, 1) + require.Equal(t, "hello from passthrough", repo.messages[0].Content) + + // Once the eager session is waiting, the title owner receives the + // instruction on a later prompt while the first prompt remains verbatim. + repo.sessions["s1"].State = models.TaskSessionStateWaitingForInput + secondReq, err := ws.NewRequest("passthrough-follow-up", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "task-quick", "session_id": "s1", "content": "follow up", + }) + require.NoError(t, err) + _, err = h.wsAddMessage(context.Background(), secondReq) + require.NoError(t, err) + require.Len(t, repo.messages, 2) + require.Contains(t, repo.messages[1].Content, "Before doing any other work, call set_task_title_kandev") +} + +func TestWSAddMessagePendingQuickChatDoesNotAssignTitleOwner(t *testing.T) { + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"task-quick": { + ID: "task-quick", + WorkspaceID: "ws-quick", + State: v1.TaskStateInProgress, + IsEphemeral: true, + Metadata: map[string]interface{}{ + models.MetaKeyAgentTitlePending: true, + }, + }}, + sessions: map[string]*models.TaskSession{"s1": { + ID: "s1", + TaskID: "task-quick", + State: models.TaskSessionStateWaitingForInput, + UpdatedAt: time.Now().UTC(), + }}, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, nil, log) + req, err := ws.NewRequest("quick-title-no-owner", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "task-quick", "session_id": "s1", "content": "No owner yet", + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Len(t, repo.messages, 1) + require.Equal(t, "No owner yet", repo.messages[0].Content) +} + +func TestWSAddMessagePendingNonEphemeralOwnerIsNotRewrapped(t *testing.T) { + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"task-normal": { + ID: "task-normal", + WorkspaceID: "ws-normal", + State: v1.TaskStateInProgress, + IsEphemeral: false, + Metadata: map[string]interface{}{ + models.MetaKeyAgentTitlePending: true, + models.MetaKeyAgentTitleOwnerSessionID: "s1", + }, + }}, + sessions: map[string]*models.TaskSession{"s1": { + ID: "s1", + TaskID: "task-normal", + State: models.TaskSessionStateWaitingForInput, + UpdatedAt: time.Now().UTC(), + }}, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, nil, log) + req, err := ws.NewRequest("normal-title-retry", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "task-normal", "session_id": "s1", "content": "Keep this ordinary task prompt unchanged", + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Len(t, repo.messages, 1) + require.Equal(t, "Keep this ordinary task prompt unchanged", repo.messages[0].Content) +} diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index c90089add2..49753d3fae 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -1861,6 +1861,7 @@ type httpStartQuickChatRequest struct { AgentProfileID string `json:"agent_profile_id,omitempty"` ExecutorID string `json:"executor_id,omitempty"` Prompt string `json:"prompt,omitempty"` + AutoTitle bool `json:"auto_title,omitempty"` LocalPath string `json:"local_path,omitempty"` RepositoryName string `json:"repository_name,omitempty"` DefaultBranch string `json:"default_branch,omitempty"` @@ -2002,6 +2003,7 @@ func (h *TaskHandlers) httpStartQuickChat(c *gin.Context) { WorkspaceID: workspaceID, Title: params.title, Description: body.Prompt, + AutoTitle: body.AutoTitle, Repositories: params.repos, IsEphemeral: true, Metadata: params.metadata, diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index d895756000..25de378860 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -127,6 +127,7 @@ func TestQuickChatResolveParamsForcesWorktreeForRepositoryContext(t *testing.T) type quickChatHandlerRepo struct { mockRepository taskRepos []*models.TaskRepository + task *models.Task } func (r *quickChatHandlerRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) { @@ -143,6 +144,11 @@ func (r *quickChatHandlerRepo) GetRepository(_ context.Context, id string) (*mod return &models.Repository{ID: id, WorkspaceID: "ws-1", Name: id, DefaultBranch: "main"}, nil } +func (r *quickChatHandlerRepo) CreateTask(_ context.Context, task *models.Task) error { + r.task = task + return nil +} + func (r *quickChatHandlerRepo) CreateTaskRepository(_ context.Context, taskRepo *models.TaskRepository) error { r.taskRepos = append(r.taskRepos, taskRepo) return nil @@ -152,7 +158,7 @@ func (r *quickChatHandlerRepo) ListTaskRepositories(_ context.Context, _ string) return r.taskRepos, nil } -func newQuickChatHandlerForTest(t *testing.T) (*TaskHandlers, *captureOrchestrator) { +func newQuickChatHandlerForTest(t *testing.T) (*TaskHandlers, *captureOrchestrator, *quickChatHandlerRepo) { t.Helper() log := newTestLogger(t) repo := &quickChatHandlerRepo{} @@ -164,7 +170,7 @@ func newQuickChatHandlerForTest(t *testing.T) (*TaskHandlers, *captureOrchestrat Reviews: repo, }, nil, log, service.RepositoryDiscoveryConfig{}) orch := &captureOrchestrator{} - return &TaskHandlers{service: svc, orchestrator: orch, logger: log}, orch + return &TaskHandlers{service: svc, orchestrator: orch, logger: log}, orch, repo } func TestHTTPStartQuickChatRejectsInvalidRepositoryShapes(t *testing.T) { @@ -193,7 +199,7 @@ func TestHTTPStartQuickChatRejectsInvalidRepositoryShapes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - h, orch := newQuickChatHandlerForTest(t) + h, orch, _ := newQuickChatHandlerForTest(t) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/workspaces/ws-1/quick-chat", strings.NewReader(tc.body)) @@ -208,6 +214,49 @@ func TestHTTPStartQuickChatRejectsInvalidRepositoryShapes(t *testing.T) { } } +func TestHTTPStartQuickChatForwardsAutoTitleAndKeepsProvisionalTitle(t *testing.T) { + gin.SetMode(gin.TestMode) + h, orch, repo := newQuickChatHandlerForTest(t) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/workspaces/ws-1/quick-chat", strings.NewReader(`{ + "title":"Agent A - Chat 1", + "agent_profile_id":"profile-1", + "auto_title":true + }`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + + h.httpStartQuickChat(c) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + require.NotNil(t, repo.task) + assert.Equal(t, "Agent A - Chat 1", repo.task.Title) + assert.True(t, models.IsAgentTitlePending(repo.task.Metadata)) + assert.Len(t, orch.requests, 1) +} + +func TestHTTPStartQuickChatWithoutAutoTitleDoesNotMarkPendingTitle(t *testing.T) { + gin.SetMode(gin.TestMode) + h, orch, repo := newQuickChatHandlerForTest(t) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/workspaces/ws-1/quick-chat", strings.NewReader(`{ + "title":"Ordinary quick chat", + "agent_profile_id":"profile-1", + "auto_title":false + }`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + + h.httpStartQuickChat(c) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + require.NotNil(t, repo.task) + assert.False(t, models.IsAgentTitlePending(repo.task.Metadata)) + assert.Len(t, orch.requests, 1) +} + // TestStartAgentForNewTask_SetsDeferredStart pins the call-site half of the // passthrough start_agent prompt-delivery fix: the synchronous prepare must // carry DeferredStart=true so launchPrepare does not eagerly upgrade a diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 76e185daaa..02dadbfb23 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -159,8 +159,8 @@ const ( // key when a session of the task next enters STARTING/RUNNING (mirrors // MetaKeyInterruptedAt). MetaKeyAutoStartFailed = "auto_start_failed" - // MetaKeyAgentTitlePending marks tasks created in prompt-first mode whose - // provisional title still needs the first eligible agent session to replace it. + // MetaKeyAgentTitlePending marks tasks whose provisional title still needs + // the first eligible agent session to replace it. MetaKeyAgentTitlePending = "agent_title_pending" // MetaKeyAgentTitleOwnerSessionID records the one session that atomically // claimed the first-turn title handoff for a pending task. @@ -192,7 +192,7 @@ const ( ) // IsAgentTitlePending reports whether task metadata contains the durable -// prompt-first title marker. JSON rehydration produces bool values, while a +// pending title marker. JSON rehydration produces bool values, while a // few in-process callers may provide typed metadata, so only an explicit true // value enables the capability. func IsAgentTitlePending(metadata map[string]interface{}) bool { diff --git a/apps/backend/internal/task/service/quick_chat_sessions.go b/apps/backend/internal/task/service/quick_chat_sessions.go index 50882f6a0e..5d0f4e3f88 100644 --- a/apps/backend/internal/task/service/quick_chat_sessions.go +++ b/apps/backend/internal/task/service/quick_chat_sessions.go @@ -32,12 +32,11 @@ type QuickChatSession struct { AgentProfileID string Session *models.TaskSession - createdAt time.Time - lastActivityAt time.Time + createdAt time.Time } // ListQuickChatSessions returns the workspace's restorable quick-chat sessions, -// most recently active first. It is the single source of truth for the quick +// oldest created first. It is the single source of truth for the quick // chat tab strip: the boot payload and the runtime resync endpoint both read it, // so a client that reloads and a client that resyncs converge on the same list. // @@ -92,7 +91,6 @@ func newQuickChatSession(task *models.Task, primary *models.TaskSession) QuickCh AgentProfileID: quickChatAgentProfileID(task, primary), Session: primary, createdAt: task.CreatedAt, - lastActivityAt: quickChatLastActivityAt(task, primary), } if task.Title != "" && task.Title != quickChatDefaultTitle { item.Name = task.Title @@ -100,13 +98,10 @@ func newQuickChatSession(task *models.Task, primary *models.TaskSession) QuickCh return item } -// sortQuickChatSessions orders tabs by most recent activity, falling back to -// creation order and then task ID so the sequence is stable across clients. +// sortQuickChatSessions orders tabs by creation time and then task ID. Activity +// updates task data but must not move a tab in the presentation baseline. func sortQuickChatSessions(items []QuickChatSession) { sort.SliceStable(items, func(i, j int) bool { - if !items[i].lastActivityAt.Equal(items[j].lastActivityAt) { - return items[i].lastActivityAt.After(items[j].lastActivityAt) - } if !items[i].createdAt.Equal(items[j].createdAt) { return items[i].createdAt.Before(items[j].createdAt) } @@ -140,13 +135,6 @@ func IsRestorableQuickChatTask(task *models.Task) bool { task.Origin != models.TaskOriginAutomationRun } -func quickChatLastActivityAt(task *models.Task, primary *models.TaskSession) time.Time { - if primary.UpdatedAt.After(task.UpdatedAt) { - return primary.UpdatedAt - } - return task.UpdatedAt -} - func quickChatSessionKind(task *models.Task) string { if task != nil { if configMode, ok := task.Metadata["config_mode"].(bool); ok && configMode { diff --git a/apps/backend/internal/task/service/quick_chat_sessions_test.go b/apps/backend/internal/task/service/quick_chat_sessions_test.go index ad4f3147ab..75f67e1225 100644 --- a/apps/backend/internal/task/service/quick_chat_sessions_test.go +++ b/apps/backend/internal/task/service/quick_chat_sessions_test.go @@ -76,8 +76,8 @@ func seedQuickChatTask(t *testing.T, svc *Service, sqlxDB *sqlx.DB, fixture quic // TestListQuickChatSessionsIsWorkspaceScopedAndOrdered pins the contract the // boot payload and the resync endpoint share: only restorable quick chats for -// the requested workspace, newest activity first. Both clients read this list, -// so any divergence here shows up as different tabs on different devices. +// the requested workspace, in a stable creation baseline. Both clients read +// this list, so activity cannot reorder tabs on different devices. func TestListQuickChatSessionsIsWorkspaceScopedAndOrdered(t *testing.T) { svc, sqlxDB := createOfficeIntegrationServiceWithDB(t) ctx := context.Background() @@ -136,7 +136,7 @@ func TestListQuickChatSessionsIsWorkspaceScopedAndOrdered(t *testing.T) { gotIDs = append(gotIDs, item.SessionID) } wantIDs := []string{ - "task-config-session", "task-new-session", "task-old-session", "task-unnamed-session", + "task-unnamed-session", "task-old-session", "task-new-session", "task-config-session", } if len(gotIDs) != len(wantIDs) { t.Fatalf("session ids = %v, want %v", gotIDs, wantIDs) @@ -146,17 +146,17 @@ func TestListQuickChatSessionsIsWorkspaceScopedAndOrdered(t *testing.T) { t.Fatalf("session ids = %v, want %v", gotIDs, wantIDs) } } - if items[0].Kind != QuickChatKindConfig || items[1].Kind != QuickChatKindChat { - t.Fatalf("kinds = %q/%q, want config/chat", items[0].Kind, items[1].Kind) + if items[3].Kind != QuickChatKindConfig || items[1].Kind != QuickChatKindChat { + t.Fatalf("kinds = %q/%q, want config/chat", items[3].Kind, items[1].Kind) } - if items[0].Name != "Config" || items[0].AgentProfileID != "agent-config" { - t.Fatalf("config tab = %#v, want title and agent profile preserved", items[0]) + if items[3].Name != "Config" || items[3].AgentProfileID != "agent-config" { + t.Fatalf("config tab = %#v, want title and agent profile preserved", items[3]) } - if items[3].Name != "" { - t.Fatalf("placeholder-titled tab name = %q, want empty", items[3].Name) + if items[0].Name != "" { + t.Fatalf("placeholder-titled tab name = %q, want empty", items[0].Name) } - if items[0].WorkspaceID != "ws-qc" || items[0].Session == nil { - t.Fatalf("tab payload = %#v, want workspace and session attached", items[0]) + if items[3].WorkspaceID != "ws-qc" || items[3].Session == nil { + t.Fatalf("tab payload = %#v, want workspace and session attached", items[3]) } } diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index bf43c08816..dc0d328730 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -62,13 +62,13 @@ var ErrInvalidTaskWorkflow = errors.New("invalid task workflow") // aborting the whole operation. var ErrTaskAlreadyArchived = errors.New("task is already archived") -// ErrAutoTitlePromptRequired is returned when prompt-first creation has no -// prompt from which to derive a provisional title. -var ErrAutoTitlePromptRequired = errors.New("description is required when auto_title is enabled") +// ErrAutoTitlePromptRequired is returned when auto-title creation has neither +// a prompt nor a usable provisional title. +var ErrAutoTitlePromptRequired = errors.New("description or title is required when auto_title is enabled") -// ErrAutoTitleUnsupportedForOffice is returned when prompt-first title -// generation is requested for an Office task. Office agents use a restricted -// MCP surface that does not expose the one-shot title tool. +// ErrAutoTitleUnsupportedForOffice is returned when auto-title generation is +// requested for an Office task. Office agents use a restricted MCP surface +// that does not expose the one-shot title tool. var ErrAutoTitleUnsupportedForOffice = errors.New("auto_title is not supported for Office tasks") type pendingTaskTitleSetter interface { @@ -430,6 +430,19 @@ func prepareAutoTitle(req *CreateTaskRequest) error { if isOfficeRequest(req) { return ErrAutoTitleUnsupportedForOffice } + if isConfigTaskMetadata(req.Metadata) { + return nil + } + if req.IsEphemeral && strings.TrimSpace(req.Description) == "" { + if strings.TrimSpace(req.Title) == "" { + return ErrAutoTitlePromptRequired + } + if req.Metadata == nil { + req.Metadata = make(map[string]interface{}) + } + req.Metadata[models.MetaKeyAgentTitlePending] = true + return nil + } title, err := deriveProvisionalTaskTitle(req.Description) if err != nil { return err @@ -1731,7 +1744,7 @@ func (s *Service) reloadTaskAfterMutation(ctx context.Context, id string, fallba return fallback } -// SetPendingAgentTitle replaces a prompt-first provisional title exactly once. +// SetPendingAgentTitle replaces a pending provisional title exactly once. // Only the atomically claimed owner session may resolve it. A missing pending // marker is an idempotent no-op so a human rename or an earlier agent call // always wins a late request. @@ -3370,7 +3383,11 @@ func parsePRQuery(query string) (int, bool) { // `json_extract(metadata, '$.config_mode') IS NOT 1` filter). JSON-decoded // numbers arrive as float64, so accept both numeric 1 and bool true. func isConfigTask(task *models.Task) bool { - switch v := task.Metadata["config_mode"].(type) { + return isConfigTaskMetadata(task.Metadata) +} + +func isConfigTaskMetadata(metadata map[string]interface{}) bool { + switch v := metadata["config_mode"].(type) { case float64: return v == 1 case int: diff --git a/apps/backend/internal/task/service/service_tasks_test.go b/apps/backend/internal/task/service/service_tasks_test.go index 578207ad08..fbb5cc7348 100644 --- a/apps/backend/internal/task/service/service_tasks_test.go +++ b/apps/backend/internal/task/service/service_tasks_test.go @@ -48,6 +48,67 @@ func TestPrepareAutoTitleRejectsOfficeRequests(t *testing.T) { } } +func TestPrepareAutoTitleQuickChatPreservesProvisionalTitleWithoutPrompt(t *testing.T) { + req := &CreateTaskRequest{ + Title: "Agent A - Chat 1", + AutoTitle: true, + IsEphemeral: true, + } + + if err := prepareAutoTitle(req); err != nil { + t.Fatalf("prepare quick chat auto title: %v", err) + } + if req.Title != "Agent A - Chat 1" { + t.Fatalf("title = %q, want provisional title", req.Title) + } + if !models.IsAgentTitlePending(req.Metadata) { + t.Fatalf("metadata = %#v, want pending agent title", req.Metadata) + } +} + +func TestPrepareAutoTitleExcludesConfigRequests(t *testing.T) { + req := &CreateTaskRequest{ + Title: "Config Chat", + AutoTitle: true, + IsEphemeral: true, + Metadata: map[string]interface{}{"config_mode": true}, + } + + if err := prepareAutoTitle(req); err != nil { + t.Fatalf("prepare config auto title: %v", err) + } + if req.Title != "Config Chat" { + t.Fatalf("title = %q, want config title", req.Title) + } + if models.IsAgentTitlePending(req.Metadata) { + t.Fatalf("metadata = %#v, config request must not become title-pending", req.Metadata) + } +} + +func TestCreateTaskQuickChatAutoTitlePersistsProvisionalTitleWithoutPrompt(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-quick-auto-title", Name: "Workspace"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + + taskResult, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-quick-auto-title", + Title: "Agent A - Chat 1", + AutoTitle: true, + IsEphemeral: true, + }) + if err != nil { + t.Fatalf("create quick chat: %v", err) + } + if taskResult.Task.Title != "Agent A - Chat 1" { + t.Fatalf("title = %q, want provisional title", taskResult.Task.Title) + } + if !models.IsAgentTitlePending(taskResult.Task.Metadata) { + t.Fatalf("metadata = %#v, want pending agent title", taskResult.Task.Metadata) + } +} + func TestCreateTaskAutoTitlePersistsPendingMarker(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index bd20787393..a2c7baf21c 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -2600,6 +2600,11 @@ type recordingTaskExecutionStopper struct { claimExecutionFunc func(sessionID, executionID string, force bool) bool } +// Durable cleanup runs on a worker and can be delayed by race-instrumented +// SQLite setup under CI load. Keep asynchronous cleanup assertions bounded +// without coupling them to a sub-second scheduler window. +const asyncTaskCleanupTestTimeout = 5 * time.Second + func newRecordingTaskExecutionStopper() *recordingTaskExecutionStopper { return &recordingTaskExecutionStopper{stopExecutionCh: make(chan stopExecutionCall, 8)} } @@ -2634,7 +2639,7 @@ func (s *recordingTaskExecutionStopper) waitForStopExecution(t *testing.T) stopE select { case call := <-s.stopExecutionCh: return call - case <-time.After(500 * time.Millisecond): + case <-time.After(asyncTaskCleanupTestTimeout): t.Fatal("timed out waiting for StopExecution") return stopExecutionCall{} } @@ -2752,7 +2757,7 @@ func waitForCleanupDone(t *testing.T, svc *Service) { t.Helper() select { case <-svc.cleanupDoneForTest: - case <-time.After(500 * time.Millisecond): + case <-time.After(asyncTaskCleanupTestTimeout): t.Fatal("timed out waiting for async task cleanup") } } diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index c22005385e..3784941fd7 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -106,6 +106,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay), AppStatusBarEnabled: req.AppStatusBarEnabled, AppStatusBarOrder: req.AppStatusBarOrder, + QuickChatTabOrderByWorkspace: req.QuickChatTabOrderByWorkspace, KanbanHiddenStepIDs: req.KanbanHiddenStepIDs, WorkflowIDsWithAutoHideEmptySteps: req.WorkflowIDsWithAutoHideEmptySteps, }) diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index b770dd3771..5bf484c906 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -71,6 +71,7 @@ type UserSettingsDTO struct { SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled bool `json:"app_status_bar_enabled"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` + QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` WorkflowIDsWithAutoHideEmptySteps []string `json:"workflow_ids_with_auto_hide_empty_steps"` Revision int64 `json:"revision"` @@ -148,6 +149,7 @@ type UpdateUserSettingsRequest struct { SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"` AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"` AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` + QuickChatTabOrderByWorkspace *map[string][]string `json:"quick_chat_tab_order_by_workspace,omitempty"` KanbanHiddenStepIDs *map[string][]string `json:"kanban_hidden_step_ids,omitempty"` WorkflowIDsWithAutoHideEmptySteps *[]string `json:"workflow_ids_with_auto_hide_empty_steps,omitempty"` } @@ -299,6 +301,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { SystemMetricsDisplay: settings.SystemMetricsDisplay, AppStatusBarEnabled: settings.AppStatusBarEnabled, AppStatusBarOrder: settings.AppStatusBarOrder, + QuickChatTabOrderByWorkspace: settings.QuickChatTabOrderByWorkspace, KanbanHiddenStepIDs: settings.KanbanHiddenStepIDs, WorkflowIDsWithAutoHideEmptySteps: append([]string{}, settings.WorkflowIDsWithAutoHideEmptySteps...), Revision: settings.Revision, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 5f680643b5..871e2130bc 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -141,6 +141,7 @@ type UserSettings struct { SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled bool `json:"app_status_bar_enabled"` AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` + QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` WorkflowIDsWithAutoHideEmptySteps []string `json:"workflow_ids_with_auto_hide_empty_steps"` Revision int64 `json:"revision"` diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 81368cb9a4..62a0c74690 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -97,6 +97,7 @@ type UpdateUserSettingsRequest struct { SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch AppStatusBarEnabled *bool AppStatusBarOrder *models.AppStatusBarOrder + QuickChatTabOrderByWorkspace *map[string][]string KanbanHiddenStepIDs *map[string][]string WorkflowIDsWithAutoHideEmptySteps *[]string } @@ -314,6 +315,12 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe if req.AppStatusBarOrder != nil { settings.AppStatusBarOrder = *req.AppStatusBarOrder } + if req.QuickChatTabOrderByWorkspace != nil { + if err := validateQuickChatTabOrder(*req.QuickChatTabOrderByWorkspace); err != nil { + return err + } + settings.QuickChatTabOrderByWorkspace = store.CloneStringSliceMap(*req.QuickChatTabOrderByWorkspace) + } if err := applyTerminalFontPreferences(settings, req); err != nil { return err } @@ -375,8 +382,11 @@ func applyWorkspaceAndTaskListPreferences(settings *models.UserSettings, req *Up } const ( - maxKanbanHiddenStepWorkflows = 200 - maxKanbanHiddenStepIDsPerWorkflow = 200 + maxQuickChatTabOrderWorkspaces = 200 + maxQuickChatTabOrderReferencesPerWorkspace = 200 + maxQuickChatTabOrderTotalBytes = maxUserPreferenceBlobBytes + maxKanbanHiddenStepWorkflows = 200 + maxKanbanHiddenStepIDsPerWorkflow = 200 // maxKanbanHiddenStepIDsTotalBytes matches maxUserPreferenceBlobBytes, the // sibling cap for other free-form settings blobs. The count caps above // bound shape (how many entries), not size (how long each string is); an @@ -390,6 +400,38 @@ const ( maxWorkflowIDsWithAutoHideEmptySteps = 200 ) +// validateQuickChatTabOrder bounds the client-supplied mixed-tab order before +// it is copied into the settings model. The shape limits keep map and slice +// allocation bounded, while the serialized limit also bounds long opaque ids. +func validateQuickChatTabOrder(orderByWorkspace map[string][]string) error { + if len(orderByWorkspace) > maxQuickChatTabOrderWorkspaces { + return fmt.Errorf( + "quick_chat_tab_order_by_workspace: max %d workspaces allowed", + maxQuickChatTabOrderWorkspaces, + ) + } + for workspaceID, references := range orderByWorkspace { + if len(references) > maxQuickChatTabOrderReferencesPerWorkspace { + return fmt.Errorf( + "quick_chat_tab_order_by_workspace[%s]: max %d tab references allowed", + workspaceID, + maxQuickChatTabOrderReferencesPerWorkspace, + ) + } + } + serialized, err := json.Marshal(orderByWorkspace) + if err != nil { + return fmt.Errorf("quick_chat_tab_order_by_workspace: must be serializable: %w", err) + } + if len(serialized) > maxQuickChatTabOrderTotalBytes { + return fmt.Errorf( + "quick_chat_tab_order_by_workspace: max %d bytes allowed", + maxQuickChatTabOrderTotalBytes, + ) + } + return nil +} + func normalizeWorkflowIDsWithAutoHideEmptySteps(workflowIDs []string) ([]string, error) { unique := make(map[string]struct{}, len(workflowIDs)) for _, workflowID := range workflowIDs { diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index d621685ad3..e6bcc1339e 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -77,6 +77,61 @@ func TestApplyBasicSettingsSystemMetricsDisplayPreservesOmittedFields(t *testing } } +func TestApplyBasicSettingsQuickChatTabOrderValidation(t *testing.T) { + tooManyWorkspaces := make(map[string][]string, 201) + for index := 0; index < 201; index++ { + tooManyWorkspaces[fmt.Sprintf("workspace-%d", index)] = []string{"conversation:one"} + } + tooManyReferences := make([]string, 201) + for index := range tooManyReferences { + tooManyReferences[index] = fmt.Sprintf("conversation:%d", index) + } + tooManyBytes := map[string][]string{ + "workspace-1": {strings.Repeat("x", maxUserPreferenceBlobBytes)}, + } + + tests := []struct { + name string + order map[string][]string + wantErr string + }{ + { + name: "rejects too many workspaces", + order: tooManyWorkspaces, + wantErr: "quick_chat_tab_order_by_workspace: max 200 workspaces allowed", + }, + { + name: "rejects too many references in one workspace", + order: map[string][]string{ + "workspace-1": tooManyReferences, + }, + wantErr: "quick_chat_tab_order_by_workspace[workspace-1]: max 200 tab references allowed", + }, + { + name: "rejects an oversized serialized order", + order: tooManyBytes, + wantErr: "quick_chat_tab_order_by_workspace: max 65536 bytes allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := &models.UserSettings{ + QuickChatTabOrderByWorkspace: map[string][]string{"existing": {"conversation:keep"}}, + } + err := applyBasicSettings(settings, &UpdateUserSettingsRequest{ + QuickChatTabOrderByWorkspace: ptr(tt.order), + }) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want error containing %q", err, tt.wantErr) + } + if !reflect.DeepEqual(settings.QuickChatTabOrderByWorkspace, map[string][]string{"existing": {"conversation:keep"}}) { + t.Fatalf("rejected update changed settings to %#v", settings.QuickChatTabOrderByWorkspace) + } + }) + } +} + // makeLayouts builds n SavedLayout fixtures with distinct IDs and names. func makeLayouts(n int) []models.SavedLayout { layouts := make([]models.SavedLayout, n) diff --git a/apps/backend/internal/user/store/quick_chat_tab_order_test.go b/apps/backend/internal/user/store/quick_chat_tab_order_test.go new file mode 100644 index 0000000000..c96a4b7d95 --- /dev/null +++ b/apps/backend/internal/user/store/quick_chat_tab_order_test.go @@ -0,0 +1,60 @@ +package store + +import ( + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/user/models" +) + +func TestQuickChatTabOrderSettingsRoundTrip(t *testing.T) { + want := map[string][]string{ + "workspace-a": {"conversation:one", "terminal:one"}, + "workspace-b": {"conversation:two"}, + } + raw, err := marshalUserSettingsPayload(&models.UserSettings{ + QuickChatTabOrderByWorkspace: want, + }) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode settings: %v", err) + } + if _, ok := payload["quick_chat_tab_order_by_workspace"]; !ok { + t.Fatal("serialized settings omitted quick_chat_tab_order_by_workspace") + } + + got, err := scanUserSettings(settingsScanner{raw: string(raw)}, DefaultUserID) + if err != nil { + t.Fatalf("scan settings: %v", err) + } + if len(got.QuickChatTabOrderByWorkspace) != len(want) { + t.Fatalf("workspace order count = %d, want %d", len(got.QuickChatTabOrderByWorkspace), len(want)) + } + for workspaceID, refs := range want { + if got.QuickChatTabOrderByWorkspace[workspaceID] == nil { + t.Fatalf("workspace %q order is missing", workspaceID) + } + if len(got.QuickChatTabOrderByWorkspace[workspaceID]) != len(refs) { + t.Fatalf("workspace %q refs = %#v, want %#v", workspaceID, got.QuickChatTabOrderByWorkspace[workspaceID], refs) + } + for index, ref := range refs { + if got.QuickChatTabOrderByWorkspace[workspaceID][index] != ref { + t.Fatalf("workspace %q ref[%d] = %q, want %q", workspaceID, index, got.QuickChatTabOrderByWorkspace[workspaceID][index], ref) + } + } + } +} + +func TestDefaultUserSettingsQuickChatTabOrderIsEmpty(t *testing.T) { + settings := defaultUserSettings(DefaultUserID) + if settings.QuickChatTabOrderByWorkspace == nil { + t.Fatal("default quick-chat tab order map is nil") + } + if len(settings.QuickChatTabOrderByWorkspace) != 0 { + t.Fatalf("default quick-chat tab order = %#v, want empty", settings.QuickChatTabOrderByWorkspace) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index f24938b5e8..089aea256a 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -562,6 +562,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { if keyboardShortcuts == nil { keyboardShortcuts = map[string]interface{}{} } + quickChatTabOrderByWorkspace := CloneStringSliceMap(settings.QuickChatTabOrderByWorkspace) return json.Marshal(map[string]interface{}{ "workspace_id": settings.WorkspaceID, "kanban_view_mode": settings.KanbanViewMode, @@ -618,6 +619,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "system_metrics_display": settings.SystemMetricsDisplay, "app_status_bar_enabled": settings.AppStatusBarEnabled, "app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), + "quick_chat_tab_order_by_workspace": quickChatTabOrderByWorkspace, "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, "workflow_ids_with_auto_hide_empty_steps": settings.WorkflowIDsWithAutoHideEmptySteps, }) @@ -698,6 +700,7 @@ func defaultUserSettings(userID string) *models.UserSettings { SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), AppStatusBarEnabled: false, AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), + QuickChatTabOrderByWorkspace: map[string][]string{}, KanbanHiddenStepIDs: map[string][]string{}, WorkflowIDsWithAutoHideEmptySteps: []string{}, } @@ -783,6 +786,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled *bool `json:"app_status_bar_enabled"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` + QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` KanbanHiddenStepIDs json.RawMessage `json:"kanban_hidden_step_ids"` WorkflowIDsWithAutoHideEmptySteps json.RawMessage `json:"workflow_ids_with_auto_hide_empty_steps"` } @@ -909,6 +913,10 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.AppStatusBarEnabled = *payload.AppStatusBarEnabled } settings.AppStatusBarOrder = normalizeAppStatusBarOrder(payload.AppStatusBarOrder) + settings.QuickChatTabOrderByWorkspace = payload.QuickChatTabOrderByWorkspace + if settings.QuickChatTabOrderByWorkspace == nil { + settings.QuickChatTabOrderByWorkspace = map[string][]string{} + } if payload.ChangesPanelLayout == "flat" { settings.ChangesPanelLayout = "flat" } else { @@ -980,6 +988,20 @@ func normalizeSidebarTaskPrefs(prefs models.SidebarTaskPrefs) models.SidebarTask return prefs } +// CloneStringSliceMap copies a per-workspace string-list map before it is +// encoded or assigned so callers cannot mutate the settings model through the +// payload. +func CloneStringSliceMap(source map[string][]string) map[string][]string { + if source == nil { + return map[string][]string{} + } + clone := make(map[string][]string, len(source)) + for key, values := range source { + clone[key] = append([]string{}, values...) + } + return clone +} + // normalizeAppStatusBarOrder defaults nil status bar item lists so the stored // JSON never carries null. func normalizeAppStatusBarOrder(order models.AppStatusBarOrder) models.AppStatusBarOrder { diff --git a/apps/web/components/quick-chat/quick-chat-dialog.tsx b/apps/web/components/quick-chat/quick-chat-dialog.tsx index e0c93e7284..b6403fc377 100644 --- a/apps/web/components/quick-chat/quick-chat-dialog.tsx +++ b/apps/web/components/quick-chat/quick-chat-dialog.tsx @@ -93,6 +93,7 @@ export const QuickChatPickerDialog = memo(function QuickChatPickerDialog({ const { t } = useTranslation(); const { toast } = useToast(); const openQuickChat = useAppStore((s) => s.openQuickChat); + const agentGeneratedTaskTitles = useAppStore((s) => s.userSettings.agentGeneratedTaskTitles); const [isStarting, setIsStarting] = useState(false); const [selectedRepoId, setSelectedRepoId] = useState(""); const [selectedAgentId, setSelectedAgentId] = useState(""); @@ -106,6 +107,7 @@ export const QuickChatPickerDialog = memo(function QuickChatPickerDialog({ const response = await startQuickChat(workspaceId, { repository_id: selectedRepoId || undefined, agent_profile_id: selectedAgentId || undefined, + ...(agentGeneratedTaskTitles ? { auto_title: true } : {}), }); onOpenChange(false); // Open the quick chat modal with the new session @@ -123,6 +125,7 @@ export const QuickChatPickerDialog = memo(function QuickChatPickerDialog({ workspaceId, selectedRepoId, selectedAgentId, + agentGeneratedTaskTitles, isStarting, onOpenChange, openQuickChat, diff --git a/apps/web/components/quick-chat/quick-chat-modal.test.tsx b/apps/web/components/quick-chat/quick-chat-modal.test.tsx index d1831c1e38..332dd21761 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.test.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.test.tsx @@ -9,6 +9,7 @@ const quickChatPrepareProgress: Record = {}; const handleActivateTerminal = vi.fn(); const handleNewTerminal = vi.fn(); const handleCloseTerminal = vi.fn(); +const persistTabOrder = vi.fn(); const handleOpenChange = vi.fn(); const handleNewChat = vi.fn(); const escapePreventDefault = vi.fn(); @@ -17,6 +18,11 @@ const WORKSPACE_ID = "ws-1"; const TERMINAL_TAB_ID = "terminal-1"; const FIRE_ESCAPE_TESTID = "fire-escape"; const TOGGLE_ESCAPE_GUARD_TESTID = "toggle-escape-guard"; +const responsiveMock = vi.hoisted(() => ({ isFinePointer: true })); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsiveMock, +})); vi.mock("@/components/state-provider", () => ({ useAppStore: ( @@ -187,6 +193,9 @@ const defaultQuickChatModalState = { handleCloseTerminal, handleConfirmClose: vi.fn(), handleRename: vi.fn(), + tabOrder: ["conversation:chat-1", "terminal:terminal-1"], + persistTabOrder, + tabOrderSyncError: null, }; import { QuickChatModal } from "./quick-chat-modal"; @@ -196,6 +205,9 @@ afterEach(() => { for (const key of Object.keys(quickChatSessionItems)) delete quickChatSessionItems[key]; for (const key of Object.keys(quickChatPrepareProgress)) delete quickChatPrepareProgress[key]; vi.clearAllMocks(); + useQuickChatModalMock.mockReset(); + useQuickChatModalMock.mockReturnValue(defaultQuickChatModalState); + responsiveMock.isFinePointer = true; }); describe("QuickChatModal mixed tabs", () => { @@ -239,6 +251,28 @@ describe("QuickChatModal mixed tabs", () => { expect(within(tabs[0]).getByRole("status", { name: "Loading" })).toBeTruthy(); expect(tabs[1].querySelector('[role="status"]')).toBeNull(); }); + + it("uses the same persisted order callback for coarse-pointer moves", () => { + responsiveMock.isFinePointer = false; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Actions for Terminal 1" })); + fireEvent.click(screen.getByRole("button", { name: "Move Terminal 1 left" })); + + expect(persistTabOrder).toHaveBeenCalledWith(["terminal:terminal-1", "conversation:chat-1"]); + }); + + it("gives coarse-pointer close controls a 44px target", () => { + responsiveMock.isFinePointer = false; + render(); + + expect(screen.getByRole("button", { name: `Actions for ${CHAT_NAME}` }).className).toContain( + "h-11 w-11", + ); + expect(screen.getByRole("button", { name: "Actions for Terminal 1" }).className).toContain( + "h-11 w-11", + ); + }); }); describe("QuickChatModal — Escape with a pending clarification", () => { diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index 851d5243a8..96656f4b9c 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -3,16 +3,13 @@ import { memo, useMemo, useRef, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; -import { Button } from "@kandev/ui/button"; -import { IconX } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; import dynamic from "@/lib/routing/client-dynamic"; +import { useAppStore } from "@/components/state-provider"; +import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session"; import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog"; import { QuickChatSessionView } from "./quick-chat-session-view"; -import { QuickChatTabItem } from "./quick-chat-tab-item"; -import { QuickTerminalTabItem } from "./quick-terminal-tab-item"; -import { QuickTabAddMenu } from "./quick-tab-add-menu"; import { QuickChatSetup } from "./quick-chat-setup"; +import { QuickChatTabs } from "./quick-chat-tab-strip"; import { useQuickChatModal } from "./use-quick-chat-modal"; import { useQuickChatWidth } from "@/hooks/use-quick-chat-width"; import { @@ -22,10 +19,6 @@ import { } from "@/hooks/use-clarification-escape-guard"; import { ConfigChatSetup } from "@/components/config-chat/config-chat-setup"; import { useConfigChat } from "@/components/config-chat/use-config-chat"; -import type { QuickChatSession, QuickTerminalTab } from "@/lib/state/slices/ui/types"; -import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session"; -import { selectQuickChatSessionIsWorking } from "@/lib/state/slices/ui/quick-chat-activity-selectors"; -import type { TFunction } from "i18next"; const QuickTerminalTabView = dynamic( () => import("./quick-terminal-tab-view").then((module) => module.QuickTerminalTabView), @@ -36,113 +29,6 @@ type QuickChatModalProps = { workspaceId: string; }; -function quickChatTabName(t: TFunction, session: QuickChatSession, index: number) { - if (!isQuickChatSetupSessionId(session.sessionId)) { - return session.name || t("chat:chatTabName", { index: index + 1 }); - } - return session.kind === "config" ? t("chat:configurationChatTab") : t("chat:newChatTab"); -} - -function QuickChatConversationTab({ - session, - ...props -}: { - session: QuickChatSession; - name: string; - isActive: boolean; - onActivate: () => void; - onClose: () => void; - onRename: (name: string) => void; -}) { - const isWorking = useAppStore((state) => - selectQuickChatSessionIsWorking(state, session.sessionId), - ); - - return ( - - ); -} - -function QuickChatTabs({ - sessions, - terminalTabs, - activeKind, - activeSessionId, - activeTerminalTabId, - onTabChange, - onTabClose, - onNewChat, - onNewTerminal, - onTerminalClose, - onTerminalActivate, - onRename, - onCloseModal, -}: { - sessions: QuickChatSession[]; - terminalTabs: QuickTerminalTab[]; - activeKind: "conversation" | "terminal"; - activeSessionId: string | null; - activeTerminalTabId: string | null; - onTabChange: (sessionId: string) => void; - onTabClose: (sessionId: string) => void; - onNewChat: () => void; - onNewTerminal: () => void; - onTerminalClose: (tabId: string) => void; - onTerminalActivate: (tabId: string) => void; - onRename: (sessionId: string, name: string) => void; - onCloseModal: () => void; -}) { - const { t } = useTranslation(); - return ( -
-
- {sessions.map((s, index) => { - const tabName = quickChatTabName(t, s, index); - return ( - onTabChange(s.sessionId)} - onClose={() => onTabClose(s.sessionId)} - onRename={(name) => onRename(s.sessionId, name)} - /> - ); - })} - {terminalTabs.map((tab) => ( - onTerminalActivate(tab.tabId)} - onClose={() => onTerminalClose(tab.tabId)} - /> - ))} - -
- {/* Touch devices have no Escape key or visible overlay to dismiss the - full-screen dialog, so give them an explicit close control. */} - -
- ); -} - type QuickChatContentProps = { workspaceId: string; configChat: ReturnType; @@ -180,6 +66,9 @@ function QuickChatContent({ onTerminalClose={quickChat.handleCloseTerminal} onRename={quickChat.handleRename} onCloseModal={() => quickChat.handleOpenChange(false)} + tabOrderSyncError={quickChat.tabOrderSyncError} + tabOrder={quickChat.tabOrder} + onTabOrderChange={quickChat.persistTabOrder} /> {quickChat.activeKind === "terminal" && quickChat.activeTerminalTab && ( ({ isFinePointer: true })); vi.mock("@kandev/ui/context-menu", () => ({ ContextMenu: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -19,7 +21,33 @@ vi.mock("@kandev/ui/context-menu", () => ({ ), })); -afterEach(cleanup); +vi.mock("@kandev/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuItem: ({ + children, + onSelect, + ...props + }: { + children: React.ReactNode; + onSelect?: () => void; + [key: string]: unknown; + }) => ( + + ), +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsiveMock, +})); + +afterEach(() => { + cleanup(); + responsiveMock.isFinePointer = true; +}); function makeProps(overrides: Partial[0]> = {}) { return { @@ -60,6 +88,67 @@ describe("QuickChatTabItem rename", () => { expect(getByLabelText("Configuration chat")).toBeTruthy(); }); + it("uses the tab surface as the sortable activator without a visible drag handle", () => { + const onActivate = vi.fn(); + const onDragStart = vi.fn(); + const setActivatorNodeRef = vi.fn(); + const dragProps = { + attributes: { "aria-roledescription": "sortable" }, + listeners: { onPointerDown: onDragStart }, + setActivatorNodeRef, + } as unknown as QuickChatTabDragProps; + + const { getByRole, getByTestId, queryByTestId } = render( + , + ); + + const tabSurface = getByTestId("quick-chat-tab"); + const tabButton = getByRole("button", { name: /^Original$/ }); + expect(tabSurface.getAttribute("aria-roledescription")).toBe("sortable"); + expect(queryByTestId("quick-chat-tab-drag-handle")).toBeNull(); + expect(setActivatorNodeRef).toHaveBeenCalledWith(tabSurface); + + fireEvent.click(tabButton); + expect(onActivate).toHaveBeenCalledOnce(); + fireEvent.pointerDown(tabSurface); + expect(onDragStart).toHaveBeenCalledOnce(); + }); +}); + +describe("QuickChatTabItem coarse-pointer actions", () => { + it("exposes reachable rename, reorder, and close actions with touch targets", () => { + responsiveMock.isFinePointer = false; + const onMoveLeft = vi.fn(); + const onMoveRight = vi.fn(); + const onClose = vi.fn(); + + const { getByRole, getAllByRole, getByLabelText } = render( + , + ); + + const actions = getByRole("button", { name: "Actions for Original" }); + expect(actions.className).toContain("h-11"); + expect(actions.className).toContain("w-11"); + + fireEvent.click(actions); + fireEvent.click(getByRole("button", { name: "Move Original left" })); + fireEvent.click(getByRole("button", { name: "Move Original right" })); + fireEvent.click(getByRole("button", { name: "Close Original" })); + + expect(onMoveLeft).toHaveBeenCalledOnce(); + expect(onMoveRight).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + + fireEvent.click(getAllByRole("button", { name: "Rename" })[0]); + expect(getByLabelText(RENAME_LABEL)).toBeTruthy(); + }); +}); + +describe("QuickChatTabItem rename actions", () => { it("commits the rename on Enter, calling onRename exactly once", () => { const onRename = vi.fn(); const { getByText, getByLabelText } = render(); @@ -73,6 +162,62 @@ describe("QuickChatTabItem rename", () => { expect(onRename).toHaveBeenCalledWith("New name"); }); + it("shows edit mode clearly without Save or Cancel buttons", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText, getByTestId, queryByRole, queryByLabelText } = render( + , + ); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + expect(input.className).toContain("border-primary"); + expect(input.className).toContain("bg-accent"); + expect(input.className).toContain("text-base"); + const tabSurface = getByTestId("quick-chat-tab"); + expect(tabSurface.className).toContain("border-primary"); + expect(tabSurface.className).toContain("bg-accent"); + expect(queryByRole("button", { name: "Save" })).toBeNull(); + expect(queryByRole("button", { name: "Cancel" })).toBeNull(); + + fireEvent.change(input, { target: { value: " Saved name " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("Saved name"); + expect(queryByLabelText(RENAME_LABEL)).toBeNull(); + }); + + it("restores the previous name with Escape without calling onRename", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText, queryByLabelText } = render( + , + ); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL); + fireEvent.change(input, { target: { value: "Draft" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onRename).not.toHaveBeenCalled(); + expect(queryByLabelText(RENAME_LABEL)).toBeNull(); + expect(getByText("Original")).toBeTruthy(); + }); + + it("commits once when editing ends through blur", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "Saved once" } }); + fireEvent.blur(input); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("Saved once"); + }); +}); + +describe("QuickChatTabItem rename edge cases", () => { it("discards the draft on Escape — onRename is NOT called even after blur fires on unmount", () => { const onRename = vi.fn(); const { getByText, getByLabelText } = render(); @@ -124,7 +269,9 @@ describe("QuickChatTabItem activity", () => { it("shows the grid spinner while its conversation is working", () => { const { getByRole } = render(); - expect(getByRole("status", { name: "Loading" })).toBeTruthy(); + const spinner = getByRole("status", { name: "Loading" }); + expect(spinner).toBeTruthy(); + expect(spinner.closest("button")?.className).toContain("gap-1.5"); }); it("does not show a spinner for a settled conversation", () => { diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.tsx index 26de8ae8c0..5381dcb6d4 100644 --- a/apps/web/components/quick-chat/quick-chat-tab-item.tsx +++ b/apps/web/components/quick-chat/quick-chat-tab-item.tsx @@ -1,8 +1,15 @@ "use client"; import { memo, useCallback, useEffect, useRef, useState } from "react"; -import type { ReactNode } from "react"; -import { IconSparkles, IconX } from "@tabler/icons-react"; +import type { ChangeEvent, FocusEvent, KeyboardEvent, ReactNode, RefObject } from "react"; +import { + IconChevronLeft, + IconChevronRight, + IconDots, + IconSparkles, + IconX, +} from "@tabler/icons-react"; +import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"; import { useTranslation } from "react-i18next"; import { ContextMenu, @@ -10,8 +17,133 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@kandev/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; import { GridSpinner } from "@/components/grid-spinner"; import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; + +export type QuickChatTabDragProps = { + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners; + setActivatorNodeRef: (node: HTMLElement | null) => void; +}; + +export type QuickChatTabActionMenuProps = { + name: string; + closeLabel?: string; + isRenameable?: boolean; + onRename?: () => void; + onMoveLeft?: () => void; + onMoveRight?: () => void; + canMoveLeft?: boolean; + canMoveRight?: boolean; + onClose: () => void; +}; + +/** Coarse-pointer action path for tab operations that cannot depend on hover or long-press. */ +export function QuickChatTabActionMenu({ + name, + closeLabel, + isRenameable = false, + onRename, + onMoveLeft, + onMoveRight, + canMoveLeft = true, + canMoveRight = true, + onClose, +}: QuickChatTabActionMenuProps) { + const { t } = useTranslation(); + const hasMoveActions = Boolean(onMoveLeft && onMoveRight); + + return ( + + + + + + {isRenameable && onRename && ( + + {t("common:rename")} + + )} + {hasMoveActions && ( + <> + + {t("chat:moveQuickChatTabLeft", { name })} + + + {t("chat:moveQuickChatTabRight", { name })} + + + )} + + {closeLabel ?? t("chat:close", { name })} + + + + ); +} + +export type QuickChatTabMoveButtonsProps = { + name: string; + onMoveLeft: () => void; + onMoveRight: () => void; + canMoveLeft: boolean; + canMoveRight: boolean; +}; + +export function QuickChatTabMoveButtons({ + name, + onMoveLeft, + onMoveRight, + canMoveLeft, + canMoveRight, +}: QuickChatTabMoveButtonsProps) { + const { t } = useTranslation(); + + return ( + <> + + + + ); +} type QuickChatTabItemProps = { name: string; @@ -22,6 +154,11 @@ type QuickChatTabItemProps = { onActivate: () => void; onClose: () => void; onRename: (name: string) => void; + onMoveLeft?: () => void; + onMoveRight?: () => void; + canMoveLeft?: boolean; + canMoveRight?: boolean; + dragProps?: QuickChatTabDragProps; }; function RenameContextMenu({ children, onRename }: { children: ReactNode; onRename: () => void }) { @@ -39,6 +176,268 @@ function RenameContextMenu({ children, onRename }: { children: ReactNode; onRena ); } +type QuickChatTabBodyProps = { + name: string; + kind: QuickChatSessionKind; + isWorking: boolean; + isEditing: boolean; + isRenameable: boolean; + draft: string; + inputRef: RefObject; + onDraftChange: (event: ChangeEvent) => void; + onBlur: (event: FocusEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; + onActivate: () => void; + onStartEdit: () => void; +}; + +function QuickChatTabBody({ + name, + kind, + isWorking, + isEditing, + isRenameable, + draft, + inputRef, + onDraftChange, + onBlur, + onKeyDown, + onActivate, + onStartEdit, +}: QuickChatTabBodyProps) { + const { t } = useTranslation(); + + if (isEditing) { + return ( + + ); + } + + return ( + + ); +} + +type QuickChatTabActionsProps = { + name: string; + isEditing: boolean; + isRenameable: boolean; + onStartEdit: () => void; + onMoveLeft?: () => void; + onMoveRight?: () => void; + canMoveLeft: boolean; + canMoveRight: boolean; + onClose: () => void; +}; + +function QuickChatTabActions({ + name, + isEditing, + isRenameable, + onStartEdit, + onMoveLeft, + onMoveRight, + canMoveLeft, + canMoveRight, + onClose, +}: QuickChatTabActionsProps) { + const { t } = useTranslation(); + const { isFinePointer } = useResponsiveBreakpoint(); + + if (isEditing) return null; + + return ( +
+ {!isFinePointer && ( + + )} + {isFinePointer && onMoveLeft && onMoveRight && ( + + )} + {isFinePointer && ( + + )} +
+ ); +} + +type QuickChatTabContentProps = { + isActive: boolean; + editContainerRef: RefObject; + bodyProps: QuickChatTabBodyProps; + actionsProps: QuickChatTabActionsProps; + dragProps?: QuickChatTabDragProps; +}; + +function QuickChatTabContent({ + isActive, + editContainerRef, + bodyProps, + actionsProps, + dragProps, +}: QuickChatTabContentProps) { + let tabStateClassName = "text-muted-foreground hover:bg-muted"; + if (bodyProps.isEditing) { + tabStateClassName = + "border border-primary bg-accent/40 text-foreground shadow-sm ring-1 ring-primary/30"; + } else if (isActive) { + tabStateClassName = "bg-background text-foreground shadow-sm"; + } + + const content = ( +
+
+ +
+ +
+ ); + + if (!bodyProps.isRenameable) return content; + return {content}; +} + +function useQuickChatTabRename({ + name, + isRenameable, + onRename, +}: Pick) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef(null); + const editContainerRef = useRef(null); + // Blur, Enter, and Escape can all finish the same edit. Guard + // the transition so one edit can produce at most one rename or restore. + const editResultRef = useRef<"open" | "committed" | "cancelled">("open"); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const commit = useCallback(() => { + if (editResultRef.current !== "open") return; + editResultRef.current = "committed"; + const trimmed = draft.trim(); + if (trimmed && trimmed !== name) onRename(trimmed); + setIsEditing(false); + }, [draft, name, onRename]); + + const cancel = useCallback(() => { + if (editResultRef.current !== "open") return; + editResultRef.current = "cancelled"; + setDraft(name); + setIsEditing(false); + }, [name]); + + const handleStartEdit = useCallback(() => { + if (!isRenameable) return; + editResultRef.current = "open"; + setDraft(name); + setIsEditing(true); + }, [isRenameable, name]); + + const handleInputBlur = useCallback( + (event: FocusEvent) => { + if ( + event.relatedTarget instanceof Node && + editContainerRef.current?.contains(event.relatedTarget) + ) { + return; + } + commit(); + }, + [commit], + ); + + const handleDraftChange = useCallback((event: ChangeEvent) => { + setDraft(event.target.value); + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + // IME composition uses Enter to confirm a candidate; let the IME + // handle it instead of committing the rename. + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + }, + [cancel, commit], + ); + + return { + isEditing, + draft, + inputRef, + editContainerRef, + handleStartEdit, + handleInputBlur, + handleDraftChange, + handleKeyDown, + }; +} + /** Tab in the quick-chat modal. Renameable tabs support double-click and context-menu editing. */ export const QuickChatTabItem = memo(function QuickChatTabItem({ name, @@ -49,109 +448,53 @@ export const QuickChatTabItem = memo(function QuickChatTabItem({ onActivate, onClose, onRename, + onMoveLeft, + onMoveRight, + canMoveLeft = true, + canMoveRight = true, + dragProps, }: QuickChatTabItemProps) { - const { t } = useTranslation(); - const [isEditing, setIsEditing] = useState(false); - const [draft, setDraft] = useState(name); - const inputRef = useRef(null); - // Both Enter and Escape close edit mode by blurring the input so onBlur is - // the single commit path. Escape additionally sets this ref so commit knows - // to skip the rename — the blur fires synchronously with the typed draft - // still in the closure, and we'd otherwise rename to whatever the user typed. - const cancelledRef = useRef(false); + const { + isEditing, + draft, + inputRef, + editContainerRef, + handleStartEdit, + handleInputBlur, + handleDraftChange, + handleKeyDown, + } = useQuickChatTabRename({ name, isRenameable, onRename }); - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - const commit = useCallback(() => { - if (cancelledRef.current) { - cancelledRef.current = false; - setIsEditing(false); - return; - } - const trimmed = draft.trim(); - if (trimmed && trimmed !== name) onRename(trimmed); - setIsEditing(false); - }, [draft, name, onRename]); - - const cancel = useCallback(() => { - cancelledRef.current = true; - setDraft(name); - inputRef.current?.blur(); - }, [name]); - - const handleStartEdit = useCallback(() => { - if (!isRenameable) return; - setDraft(name); - setIsEditing(true); - }, [isRenameable, name]); - - const tabContent = ( -
- {isEditing ? ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - // IME composition uses Enter to confirm a candidate; let the IME - // handle it instead of committing the rename. - if (e.nativeEvent.isComposing) return; - if (e.key === "Enter") { - e.preventDefault(); - // Trigger blur instead of calling commit() directly: the input - // unmount on setIsEditing(false) would otherwise fire onBlur and - // call commit() a second time. - inputRef.current?.blur(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancel(); - } - }} - className="px-2.5 py-1 text-xs bg-background border border-input rounded outline-none focus:ring-1 focus:ring-ring max-w-[160px]" - /> - ) : ( - - )} - -
+ return ( + ); - - if (!isRenameable) return tabContent; - - return {tabContent}; }); diff --git a/apps/web/components/quick-chat/quick-chat-tab-strip.tsx b/apps/web/components/quick-chat/quick-chat-tab-strip.tsx new file mode 100644 index 0000000000..2ead74fd59 --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-tab-strip.tsx @@ -0,0 +1,412 @@ +"use client"; + +import { useCallback, useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + horizontalListSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Button } from "@kandev/ui/button"; +import { IconX } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { selectQuickChatSessionIsWorking } from "@/lib/state/slices/ui/quick-chat-activity-selectors"; +import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session"; +import type { QuickChatSession, QuickTerminalTab } from "@/lib/state/slices/ui/types"; +import type { TFunction } from "i18next"; +import { cn } from "@/lib/utils"; +import { QuickChatTabItem, type QuickChatTabDragProps } from "./quick-chat-tab-item"; +import { QuickTerminalTabItem } from "./quick-terminal-tab-item"; +import { QuickTabAddMenu } from "./quick-tab-add-menu"; +import { conversationTabReference, terminalTabReference } from "./use-quick-chat-tab-order"; + +const DRAG_ACTIVATION_DISTANCE = 8; +const TOUCH_DRAG_DELAY_MS = 250; +const TOUCH_DRAG_TOLERANCE = 5; + +function quickChatTabName(t: TFunction, session: QuickChatSession, index: number) { + if (!isQuickChatSetupSessionId(session.sessionId)) { + return session.name || t("chat:chatTabName", { index: index + 1 }); + } + return session.kind === "config" ? t("chat:configurationChatTab") : t("chat:newChatTab"); +} + +function QuickChatConversationTab({ + session, + ...props +}: { + session: QuickChatSession; + name: string; + isActive: boolean; + onActivate: () => void; + onClose: () => void; + onRename: (name: string) => void; + onMoveLeft?: () => void; + onMoveRight?: () => void; + canMoveLeft?: boolean; + canMoveRight?: boolean; + dragProps?: QuickChatTabDragProps; +}) { + const isWorking = useAppStore((state) => + selectQuickChatSessionIsWorking(state, session.sessionId), + ); + + return ( + + ); +} + +type SortableQuickChatTabProps = { + reference: string; + children: (dragProps: QuickChatTabDragProps) => ReactNode; +}; + +function SortableQuickChatTab({ reference, children }: SortableQuickChatTabProps) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: reference }); + return ( +
+ {children({ attributes, listeners, setActivatorNodeRef })} +
+ ); +} + +function useQuickChatTabDnd(sortableOrder: string[], onTabOrderChange?: (order: string[]) => void) { + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }), + useSensor(TouchSensor, { + activationConstraint: { delay: TOUCH_DRAG_DELAY_MS, tolerance: TOUCH_DRAG_TOLERANCE }, + }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const moveTab = useCallback( + (reference: string, delta: -1 | 1) => { + if (!onTabOrderChange) return; + const index = sortableOrder.indexOf(reference); + const targetIndex = index + delta; + if (index < 0 || targetIndex < 0 || targetIndex >= sortableOrder.length) return; + onTabOrderChange(arrayMove(sortableOrder, index, targetIndex)); + }, + [onTabOrderChange, sortableOrder], + ); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + if (!onTabOrderChange || !event.over || event.active.id === event.over.id) return; + const activeIndex = sortableOrder.indexOf(String(event.active.id)); + const overIndex = sortableOrder.indexOf(String(event.over.id)); + if (activeIndex < 0 || overIndex < 0) return; + onTabOrderChange(arrayMove(sortableOrder, activeIndex, overIndex)); + }, + [onTabOrderChange, sortableOrder], + ); + + return { sensors, moveTab, handleDragEnd }; +} + +type QuickChatSortableTabItemProps = { + reference: string; + session?: QuickChatSession; + terminal?: QuickTerminalTab; + name?: string; + isActive: boolean; + canMoveLeft: boolean; + canMoveRight: boolean; + onTabChange: (sessionId: string) => void; + onTabClose: (sessionId: string) => void; + onTerminalClose: (tabId: string) => void; + onTerminalActivate: (tabId: string) => void; + onRename: (sessionId: string, name: string) => void; + moveTab: (reference: string, delta: -1 | 1) => void; +}; + +function QuickChatSortableTabItem({ + reference, + session, + terminal, + name, + isActive, + canMoveLeft, + canMoveRight, + onTabChange, + onTabClose, + onTerminalClose, + onTerminalActivate, + onRename, + moveTab, +}: QuickChatSortableTabItemProps) { + return ( + + {(dragProps) => { + if (session) { + return ( + onTabChange(session.sessionId)} + onClose={() => onTabClose(session.sessionId)} + onRename={(nextName) => onRename(session.sessionId, nextName)} + onMoveLeft={() => moveTab(reference, -1)} + onMoveRight={() => moveTab(reference, 1)} + canMoveLeft={canMoveLeft} + canMoveRight={canMoveRight} + dragProps={dragProps} + /> + ); + } + if (!terminal) return null; + return ( + onTerminalActivate(terminal.tabId)} + onClose={() => onTerminalClose(terminal.tabId)} + onMoveLeft={() => moveTab(reference, -1)} + onMoveRight={() => moveTab(reference, 1)} + canMoveLeft={canMoveLeft} + canMoveRight={canMoveRight} + dragProps={dragProps} + /> + ); + }} + + ); +} + +type QuickChatTabStripProps = { + sessions: QuickChatSession[]; + sortableOrder: string[]; + sessionByReference: Map; + terminalByReference: Map; + activeKind: "conversation" | "terminal"; + activeSessionId: string | null; + activeTerminalTabId: string | null; + onTabChange: (sessionId: string) => void; + onTabClose: (sessionId: string) => void; + onNewChat: () => void; + onNewTerminal: () => void; + onTerminalClose: (tabId: string) => void; + onTerminalActivate: (tabId: string) => void; + onRename: (sessionId: string, name: string) => void; + moveTab: (reference: string, delta: -1 | 1) => void; + onCloseModal: () => void; +}; + +function QuickChatTabStrip({ + sessions, + sortableOrder, + sessionByReference, + terminalByReference, + activeKind, + activeSessionId, + activeTerminalTabId, + onTabChange, + onTabClose, + onNewChat, + onNewTerminal, + onTerminalClose, + onTerminalActivate, + onRename, + moveTab, + onCloseModal, +}: QuickChatTabStripProps) { + const { t } = useTranslation(); + const setupSessions = sessions.filter((session) => isQuickChatSetupSessionId(session.sessionId)); + + return ( +
+
+ {sortableOrder.map((reference, index) => { + const session = sessionByReference.get(reference); + const terminal = terminalByReference.get(reference); + if (!session && !terminal) return null; + const sessionIndex = session + ? sessions.findIndex((item) => item.sessionId === session.sessionId) + : -1; + return ( + 0} + canMoveRight={index < sortableOrder.length - 1} + onTabChange={onTabChange} + onTabClose={onTabClose} + onTerminalClose={onTerminalClose} + onTerminalActivate={onTerminalActivate} + onRename={onRename} + moveTab={moveTab} + /> + ); + })} + {setupSessions.map((session, index) => ( + onTabChange(session.sessionId)} + onClose={() => onTabClose(session.sessionId)} + onRename={(name) => onRename(session.sessionId, name)} + /> + ))} + +
+ {/* Touch devices have no Escape key or visible overlay to dismiss the + full-screen dialog, so give them an explicit close control. */} + +
+ ); +} + +type QuickChatTabsProps = { + sessions: QuickChatSession[]; + terminalTabs: QuickTerminalTab[]; + activeKind: "conversation" | "terminal"; + activeSessionId: string | null; + activeTerminalTabId: string | null; + onTabChange: (sessionId: string) => void; + onTabClose: (sessionId: string) => void; + onNewChat: () => void; + onNewTerminal: () => void; + onTerminalClose: (tabId: string) => void; + onTerminalActivate: (tabId: string) => void; + onRename: (sessionId: string, name: string) => void; + onCloseModal: () => void; + tabOrderSyncError: string | null; + tabOrder?: string[]; + onTabOrderChange?: (order: string[]) => void; +}; + +export function QuickChatTabs({ + sessions, + terminalTabs, + activeKind, + activeSessionId, + activeTerminalTabId, + onTabChange, + onTabClose, + onNewChat, + onNewTerminal, + onTerminalClose, + onTerminalActivate, + onRename, + onCloseModal, + tabOrderSyncError, + tabOrder, + onTabOrderChange, +}: QuickChatTabsProps) { + const { t } = useTranslation(); + const fallbackOrder = useMemo( + () => [ + ...sessions + .filter((session) => !isQuickChatSetupSessionId(session.sessionId)) + .map((session) => conversationTabReference(session.sessionId)), + ...terminalTabs.map((tab) => terminalTabReference(tab.tabId)), + ], + [sessions, terminalTabs], + ); + const sortableOrder = tabOrder ?? fallbackOrder; + const sessionByReference = useMemo( + () => + new Map( + sessions + .filter((session) => !isQuickChatSetupSessionId(session.sessionId)) + .map((session) => [conversationTabReference(session.sessionId), session]), + ), + [sessions], + ); + const terminalByReference = useMemo( + () => new Map(terminalTabs.map((tab) => [terminalTabReference(tab.tabId), tab])), + [terminalTabs], + ); + const { sensors, moveTab, handleDragEnd } = useQuickChatTabDnd(sortableOrder, onTabOrderChange); + return ( + <> + + + + + + {tabOrderSyncError && ( +
+ {t("chat:quickChatTabOrderSaveFailed")}{" "} + {t("chat:quickChatTabOrderSaveFailedDescription")} +
+ )} + + ); +} diff --git a/apps/web/components/quick-chat/quick-terminal-tab-item.tsx b/apps/web/components/quick-chat/quick-terminal-tab-item.tsx index 39da927d0e..26cc138a3d 100644 --- a/apps/web/components/quick-chat/quick-terminal-tab-item.tsx +++ b/apps/web/components/quick-chat/quick-terminal-tab-item.tsx @@ -3,6 +3,12 @@ import { memo } from "react"; import { IconAlertCircle, IconTerminal2, IconX } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { + QuickChatTabActionMenu, + QuickChatTabMoveButtons, + type QuickChatTabDragProps, +} from "./quick-chat-tab-item"; type QuickTerminalTabItemProps = { sequence: number; @@ -10,8 +16,71 @@ type QuickTerminalTabItemProps = { error?: string; onActivate: () => void; onClose: () => void; + onMoveLeft?: () => void; + onMoveRight?: () => void; + canMoveLeft?: boolean; + canMoveRight?: boolean; + dragProps?: QuickChatTabDragProps; }; +type QuickTerminalTabActionsProps = { + label: string; + closeLabel: string; + onClose: () => void; + onMoveLeft?: () => void; + onMoveRight?: () => void; + canMoveLeft: boolean; + canMoveRight: boolean; +}; + +function QuickTerminalTabActions({ + label, + closeLabel, + onClose, + onMoveLeft, + onMoveRight, + canMoveLeft, + canMoveRight, +}: QuickTerminalTabActionsProps) { + const { isFinePointer } = useResponsiveBreakpoint(); + + return ( +
+ {!isFinePointer && ( + + )} + {isFinePointer && onMoveLeft && onMoveRight && ( + + )} + {isFinePointer && ( + + )} +
+ ); +} + /** Fixed-label tab for a browser-local quick terminal. */ export const QuickTerminalTabItem = memo(function QuickTerminalTabItem({ sequence, @@ -19,6 +88,11 @@ export const QuickTerminalTabItem = memo(function QuickTerminalTabItem({ error, onActivate, onClose, + onMoveLeft, + onMoveRight, + canMoveLeft = true, + canMoveRight = true, + dragProps, }: QuickTerminalTabItemProps) { const { t } = useTranslation(); const label = t("sidebar:quickChatTerminalTab", { count: sequence }); @@ -26,13 +100,16 @@ export const QuickTerminalTabItem = memo(function QuickTerminalTabItem({ return (
- +
); }); diff --git a/apps/web/components/quick-chat/use-quick-chat-close-actions.ts b/apps/web/components/quick-chat/use-quick-chat-close-actions.ts new file mode 100644 index 0000000000..d5a5e740bd --- /dev/null +++ b/apps/web/components/quick-chat/use-quick-chat-close-actions.ts @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/toast-provider"; +import { cancelPtyTerminalStart } from "@/components/settings/pty-terminal-lifecycle"; +import { deleteQuickTerminalTab } from "@/lib/api/domains/quick-terminal-api"; +import { ApiError } from "@/lib/api/client"; +import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session"; +import type { + QuickChatActiveKind, + QuickChatSession, + QuickTerminalTab, + QuickTerminalUpdate, +} from "@/lib/state/slices/ui/types"; +import { + adjacentQuickChatTabReference, + conversationTabReference, + terminalTabReference, +} from "./use-quick-chat-tab-order"; + +export type QuickChatCloseStore = { + sessions: QuickChatSession[]; + terminalTabs: QuickTerminalTab[]; + activeSessionId: string | null; + activeKind: QuickChatActiveKind; + activeTerminalTabId: string | null; + taskSessions: Record; + closeQuickChatSession: (sessionId: string) => void; + removeQuickChatSession: (sessionId: string) => void; + setActiveQuickChatSession: (sessionId: string, workspaceId: string) => void; + activateQuickTerminal: (tabId: string, workspaceId: string) => void; + updateQuickTerminal: (tabId: string, update: QuickTerminalUpdate) => void; + removeQuickTerminal: (tabId: string) => void; +}; + +export function resolveQuickChatTaskId( + store: Pick, + sessionId: string, +): string | undefined { + return ( + store.sessions.find((session) => session.sessionId === sessionId)?.taskId ?? + store.taskSessions[sessionId]?.task_id + ); +} + +async function deleteQuickChatTask(taskId: string) { + const { deleteTask } = await import("@/lib/api/domains/kanban-api"); + await deleteTask(taskId); +} + +type QuickChatTabCloseNavigation = { + tabOrder: string[]; + activeTabReference: string | undefined; + onActivateTabReference: (reference: string) => void; +}; + +function getActiveQuickChatTabReference( + store: Pick, +): string | undefined { + if (store.activeKind === "conversation" && store.activeSessionId) { + return conversationTabReference(store.activeSessionId); + } + if (store.activeKind === "terminal" && store.activeTerminalTabId) { + return terminalTabReference(store.activeTerminalTabId); + } + return undefined; +} + +function activateQuickChatTabReference( + store: Pick, + workspaceId: string, + reference: string, +): void { + if (reference.startsWith("conversation:")) { + const sessionId = reference.slice("conversation:".length); + if (sessionId) store.setActiveQuickChatSession(sessionId, workspaceId); + return; + } + if (reference.startsWith("terminal:")) { + const tabId = reference.slice("terminal:".length); + if (tabId) store.activateQuickTerminal(tabId, workspaceId); + } +} + +function useQuickChatSessionClose( + store: QuickChatCloseStore, + resetPendingStarts: () => void, + removeTabReference: (reference: string) => void, + navigation: QuickChatTabCloseNavigation, +) { + const { t } = useTranslation(); + const { toast } = useToast(); + const { activeTabReference, onActivateTabReference, tabOrder } = navigation; + const [sessionToClose, setSessionToClose] = useState(null); + const [replacementReference, setReplacementReference] = useState(null); + + const handleCloseTab = useCallback( + (sessionId: string) => { + resetPendingStarts(); + if (isQuickChatSetupSessionId(sessionId)) { + setReplacementReference(null); + store.closeQuickChatSession(sessionId); + return; + } + const reference = conversationTabReference(sessionId); + setReplacementReference( + activeTabReference === reference + ? (adjacentQuickChatTabReference(tabOrder, reference) ?? null) + : null, + ); + setSessionToClose(sessionId); + }, + [activeTabReference, resetPendingStarts, store, tabOrder], + ); + + const handleConfirmClose = useCallback(async () => { + if (!sessionToClose) return; + const sessionId = sessionToClose; + const replacement = replacementReference; + setSessionToClose(null); + setReplacementReference(null); + const taskId = resolveQuickChatTaskId(store, sessionId); + if (!taskId) { + store.removeQuickChatSession(sessionId); + removeTabReference(conversationTabReference(sessionId)); + if (replacement) onActivateTabReference(replacement); + return; + } + try { + await deleteQuickChatTask(taskId); + store.removeQuickChatSession(sessionId); + removeTabReference(conversationTabReference(sessionId)); + if (replacement) onActivateTabReference(replacement); + } catch (error) { + console.error("Failed to delete quick chat task:", error); + toast({ + title: t("chat:failedToDeleteQuickChat"), + description: error instanceof Error ? error.message : t("chat:unknownError"), + variant: "error", + }); + } + }, [ + onActivateTabReference, + removeTabReference, + replacementReference, + sessionToClose, + store, + t, + toast, + ]); + + return { sessionToClose, setSessionToClose, handleCloseTab, handleConfirmClose }; +} + +function useQuickTerminalClose( + store: QuickChatCloseStore, + resetPendingStarts: () => void, + removeTabReference: (reference: string) => void, + navigation: QuickChatTabCloseNavigation, +) { + const { toast } = useToast(); + const { t } = useTranslation(); + const { activeTabReference, onActivateTabReference, tabOrder } = navigation; + + const handleCloseTerminal = useCallback( + async (tabId: string) => { + resetPendingStarts(); + cancelPtyTerminalStart(tabId); + const tab = store.terminalTabs.find((item) => item.tabId === tabId); + if (!tab) return; + const reference = terminalTabReference(tabId); + const replacement = + activeTabReference === reference + ? adjacentQuickChatTabReference(tabOrder, reference) + : undefined; + try { + await deleteQuickTerminalTab(tabId); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + store.removeQuickTerminal(tabId); + removeTabReference(reference); + if (replacement) onActivateTabReference(replacement); + return; + } + const message = error instanceof Error ? error.message : String(error); + store.updateQuickTerminal(tabId, { status: "error", error: message }); + toast({ + title: t("sidebar:quickChatTerminals"), + description: t("sidebar:quickChatTerminalError", { error: message }), + variant: "error", + }); + return; + } + store.removeQuickTerminal(tabId); + removeTabReference(reference); + if (replacement) onActivateTabReference(replacement); + }, + [ + activeTabReference, + onActivateTabReference, + removeTabReference, + resetPendingStarts, + store, + t, + tabOrder, + toast, + ], + ); + + return handleCloseTerminal; +} + +type QuickChatCloseActionsOptions = { + workspaceId: string; + store: QuickChatCloseStore; + resetPendingStarts: () => void; + removeTabReference: (reference: string) => void; + tabOrder: string[]; +}; + +export function useQuickChatCloseActions({ + workspaceId, + store, + resetPendingStarts, + removeTabReference, + tabOrder, +}: QuickChatCloseActionsOptions) { + const activeTabReference = getActiveQuickChatTabReference(store); + const onActivateTabReference = useCallback( + (reference: string) => activateQuickChatTabReference(store, workspaceId, reference), + [store, workspaceId], + ); + const navigation = { tabOrder, activeTabReference, onActivateTabReference }; + const sessionClose = useQuickChatSessionClose( + store, + resetPendingStarts, + removeTabReference, + navigation, + ); + const handleCloseTerminal = useQuickTerminalClose( + store, + resetPendingStarts, + removeTabReference, + navigation, + ); + + return { ...sessionClose, handleCloseTerminal }; +} diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.test.ts b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts index ace3431d2a..bea2b797b6 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.test.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts @@ -14,6 +14,7 @@ let mockAppState: ReturnType; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: ReturnType) => unknown) => selector(mockAppState), + useAppStoreApi: () => ({ getState: () => mockAppState }), })); vi.mock("@/components/toast-provider", () => ({ @@ -60,7 +61,11 @@ function makeAppState() { activeKind: "conversation" as const, activeTerminalTabId: null, terminalTabs: [] as QuickTerminalTab[], + tabOrderByWorkspace: {} as Record, + tabOrderSyncErrorByWorkspace: {} as Record, + tabOrderSyncPendingByWorkspace: {} as Record, }, + userSettings: { quickChatTabOrderByWorkspace: {}, agentGeneratedTaskTitles: true }, closeQuickChat: vi.fn(), closeQuickChatSession: vi.fn(), removeQuickChatSession: vi.fn(), @@ -71,6 +76,9 @@ function makeAppState() { removeQuickTerminal: vi.fn(), renameQuickChatSession: vi.fn(), openQuickChat: vi.fn(), + setQuickChatTabOrder: vi.fn(), + clearQuickChatTabOrder: vi.fn(), + setQuickChatTabOrderSyncState: vi.fn(), agentProfiles: { items: [] }, taskSessions: { items: {} as Record }, }; @@ -98,6 +106,7 @@ function makeStore(overrides: Partial = {}): MockStore { { id: "agent-a", label: "Agent A", agent_id: "a", agent_name: "Agent A" }, { id: "agent-b", label: "Agent B", agent_id: "b", agent_name: "Agent B" }, ] as MockStore["agentProfiles"], + agentGeneratedTaskTitles: true, taskSessions: {}, ...overrides, }; @@ -299,6 +308,40 @@ describe("useQuickChatModal — persisted config lifecycle", () => { consoleError.mockRestore(); }); + it("activates the adjacent mixed tab after closing the active conversation", async () => { + mockAppState.quickChat.sessions = [ + { + sessionId: SESSION_ONE_ID, + workspaceId: WORKSPACE_ID, + kind: "chat", + taskId: "chat-task", + }, + ]; + mockAppState.quickChat.terminalTabs = [ + { + tabId: TERMINAL_ONE_ID, + workspaceId: WORKSPACE_ID, + sessionId: "terminal-session", + sequence: 1, + status: "running", + }, + ]; + mockAppState.quickChat.activeKind = "conversation"; + mockAppState.quickChat.activeSessionId = SESSION_ONE_ID; + mockAppState.userSettings.quickChatTabOrderByWorkspace = { + [WORKSPACE_ID]: [`terminal:${TERMINAL_ONE_ID}`, `conversation:${SESSION_ONE_ID}`], + }; + mockDeleteTask.mockResolvedValue(undefined); + + const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID)); + + act(() => result.current.handleCloseTab(SESSION_ONE_ID)); + await act(async () => result.current.handleConfirmClose()); + + expect(mockAppState.removeQuickChatSession).toHaveBeenCalledWith(SESSION_ONE_ID); + expect(mockAppState.activateQuickTerminal).toHaveBeenCalledWith(TERMINAL_ONE_ID, WORKSPACE_ID); + }); + it("exposes only sessions from the hydrated workspace", () => { mockAppState.quickChat.sessions = [ { sessionId: "session-a", workspaceId: WORKSPACE_ID, kind: "chat" }, @@ -356,6 +399,38 @@ describe("useAgentSelection — happy path", () => { ); }); + it("forwards the enabled agent-title preference to the start request", async () => { + const store = makeStore({ agentGeneratedTaskTitles: true }); + mockStartQuickChat.mockResolvedValue({ task_id: "task-a", session_id: "sess-a" }); + + const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); + + await act(async () => { + await result.current.handleSelectAgent("agent-a"); + }); + + expect(mockStartQuickChat).toHaveBeenCalledWith( + WORKSPACE_ID, + expect.objectContaining({ auto_title: true }), + ); + }); + + it("omits agent-title generation when the preference is disabled", async () => { + const store = makeStore({ agentGeneratedTaskTitles: false }); + mockStartQuickChat.mockResolvedValue({ task_id: "task-a", session_id: "sess-a" }); + + const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); + + await act(async () => { + await result.current.handleSelectAgent("agent-a"); + }); + + expect(mockStartQuickChat).toHaveBeenCalledWith( + WORKSPACE_ID, + expect.not.objectContaining({ auto_title: true }), + ); + }); + it("numbers chats using only ordinary sessions in the current workspace", async () => { const store = makeStore({ sessions: [ diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index 2f0b5b474c..314ff528de 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -6,16 +6,14 @@ import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { startQuickChat, type QuickChatRepositoryInput } from "@/lib/api/domains/workspace-api"; -import { - deleteQuickTerminalTab, - updateQuickTerminalTab, -} from "@/lib/api/domains/quick-terminal-api"; +import { updateQuickTerminalTab } from "@/lib/api/domains/quick-terminal-api"; import { ApiError } from "@/lib/api/client"; import { type PtyTerminalState } from "@/components/settings/pty-terminal-view"; -import { cancelPtyTerminalStart } from "@/components/settings/pty-terminal-lifecycle"; import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session"; import { persistQuickChatRename } from "@/lib/quick-chat/rename"; import type { QuickChatSessionKind, QuickTerminalTab } from "@/lib/state/slices/ui/types"; +import { useQuickChatCloseActions, resolveQuickChatTaskId } from "./use-quick-chat-close-actions"; +import { useQuickChatTabOrder } from "./use-quick-chat-tab-order"; const noop = () => {}; @@ -44,6 +42,7 @@ function useQuickChatStore(workspaceId: string) { renameQuickChatSession: s.renameQuickChatSession, openQuickChat: s.openQuickChat, agentProfiles: s.agentProfiles.items ?? [], + agentGeneratedTaskTitles: s.userSettings.agentGeneratedTaskTitles, taskSessions: s.taskSessions.items || {}, })), ); @@ -108,20 +107,6 @@ function applyQuickTerminalDescriptor( }); } -/** - * Resolves a tab's backing task. The tab carries `taskId` on every path that - * introduces it, but sessions restored by older clients only exist in - * `taskSessions`. Both the rename and the close flow must agree here: a missed - * task id silently downgrades a rename to device-local and skips the backend - * delete, leaving an orphaned ephemeral task. - */ -function resolveTaskId(store: QuickChatStore, sessionId: string): string | undefined { - return ( - store.sessions.find((session) => session.sessionId === sessionId)?.taskId ?? - store.taskSessions[sessionId]?.task_id - ); -} - function useWorkspaceQuickChat(store: QuickChatStore) { const sessions = store.sessions; const terminalTabs = store.terminalTabs; @@ -174,6 +159,7 @@ async function startQuickChatForAgent( const response = await startQuickChat(workspaceId, { agent_profile_id: agentId, title: initialName, + ...(store.agentGeneratedTaskTitles ? { auto_title: true } : {}), repositories: repositories.length > 0 ? repositories : undefined, }); return { sessionId: response.session_id, name: initialName, taskId: response.task_id }; @@ -241,85 +227,14 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) { return { pendingAgentId, reset, handleSelectAgent }; } -function useQuickChatSessionClose(store: QuickChatStore, resetPendingStarts: () => void) { - const { t } = useTranslation(); - const { toast } = useToast(); - const [sessionToClose, setSessionToClose] = useState(null); - const handleCloseTab = useCallback( - (sessionId: string) => { - resetPendingStarts(); - if (isQuickChatSetupSessionId(sessionId)) { - store.closeQuickChatSession(sessionId); - return; - } - setSessionToClose(sessionId); - }, - [resetPendingStarts, store], - ); - const handleConfirmClose = useCallback(async () => { - if (!sessionToClose) return; - const sessionId = sessionToClose; - setSessionToClose(null); - const taskId = resolveTaskId(store, sessionId); - if (!taskId) { - store.removeQuickChatSession(sessionId); - return; - } - try { - await deleteQuickChatTask(taskId); - store.removeQuickChatSession(sessionId); - } catch (error) { - console.error("Failed to delete quick chat task:", error); - toast({ - title: t("chat:failedToDeleteQuickChat"), - description: error instanceof Error ? error.message : t("chat:unknownError"), - variant: "error", - }); - } - }, [sessionToClose, store, toast]); - return { sessionToClose, setSessionToClose, handleCloseTab, handleConfirmClose }; -} - -function useQuickTerminalClose(store: QuickChatStore, resetPendingStarts: () => void) { - const { toast } = useToast(); - const { t } = useTranslation(); - - const handleCloseTerminal = useCallback( - async (tabId: string) => { - resetPendingStarts(); - cancelPtyTerminalStart(tabId); - const tab = store.terminalTabs.find((item) => item.tabId === tabId); - if (!tab) return; - try { - await deleteQuickTerminalTab(tabId); - } catch (error) { - if (error instanceof ApiError && error.status === 404) { - store.removeQuickTerminal(tabId); - return; - } - const message = error instanceof Error ? error.message : String(error); - store.updateQuickTerminal(tabId, { status: "error", error: message }); - toast({ - title: t("sidebar:quickChatTerminals"), - description: t("sidebar:quickChatTerminalError", { error: message }), - variant: "error", - }); - return; - } - store.removeQuickTerminal(tabId); - }, - [resetPendingStarts, store, t, toast], - ); - - return handleCloseTerminal; -} - type QuickChatTabActionsOptions = { workspaceId: string; sessions: QuickChatStore["sessions"]; activeSession: QuickChatStore["sessions"][number] | undefined; store: QuickChatStore; resetPendingStarts: () => void; + removeTabReference: (reference: string) => void; + tabOrder: string[]; setSetupKey: React.Dispatch>; }; @@ -331,13 +246,15 @@ function useQuickChatRename(store: QuickChatStore) { (sessionId: string, name: string) => { if (!sessionId) return; store.renameQuickChatSession(sessionId, name); - persistQuickChatRename(sessionId, resolveTaskId(store, sessionId), name).catch(() => { - toast({ - title: t("chat:renameSavedOnThisDeviceOnly"), - description: t("chat:renameSyncFailedDescription"), - variant: "error", - }); - }); + persistQuickChatRename(sessionId, resolveQuickChatTaskId(store, sessionId), name).catch( + () => { + toast({ + title: t("chat:renameSavedOnThisDeviceOnly"), + description: t("chat:renameSyncFailedDescription"), + variant: "error", + }); + }, + ); }, [store, t, toast], ); @@ -349,11 +266,23 @@ function useQuickChatTabActions({ activeSession, store, resetPendingStarts, + removeTabReference, + tabOrder, setSetupKey, }: QuickChatTabActionsOptions) { - const { sessionToClose, setSessionToClose, handleCloseTab, handleConfirmClose } = - useQuickChatSessionClose(store, resetPendingStarts); - const handleCloseTerminal = useQuickTerminalClose(store, resetPendingStarts); + const { + sessionToClose, + setSessionToClose, + handleCloseTab, + handleConfirmClose, + handleCloseTerminal, + } = useQuickChatCloseActions({ + workspaceId, + store, + resetPendingStarts, + removeTabReference, + tabOrder, + }); const handleRename = useQuickChatRename(store); const handleOpenChange = useCallback( @@ -441,7 +370,15 @@ function useQuickChatTabActions({ export function useQuickChatModal(workspaceId: string, onSupersedeConfigStart = noop) { const store = useQuickChatStore(workspaceId); - const { sessions, terminalTabs, activeSession, activeTerminalTab } = useWorkspaceQuickChat(store); + const { + sessions: workspaceSessions, + terminalTabs: workspaceTerminalTabs, + activeSession, + activeTerminalTab, + } = useWorkspaceQuickChat(store); + const tabOrder = useQuickChatTabOrder(workspaceId, workspaceSessions, workspaceTerminalTabs); + const sessions = tabOrder.sessions; + const terminalTabs = tabOrder.terminalTabs; const [setupKey, setSetupKey] = useState(0); const { pendingAgentId, @@ -458,6 +395,8 @@ export function useQuickChatModal(workspaceId: string, onSupersedeConfigStart = activeSession, store, resetPendingStarts, + removeTabReference: tabOrder.removeTabReference, + tabOrder: tabOrder.order, setSetupKey, }); @@ -477,6 +416,10 @@ export function useQuickChatModal(workspaceId: string, onSupersedeConfigStart = activeSessionId: activeSession?.sessionId ?? null, activeSession, ...tabActions, + tabOrder: tabOrder.order, + persistTabOrder: tabOrder.persistOrder, + tabOrderSyncError: tabOrder.syncError, + tabOrderSyncPending: tabOrder.syncPending, setupKey, activeSessionNeedsAgent: Boolean( activeSession && isQuickChatSetupSessionId(activeSession.sessionId), diff --git a/apps/web/components/quick-chat/use-quick-chat-tab-order.test.ts b/apps/web/components/quick-chat/use-quick-chat-tab-order.test.ts new file mode 100644 index 0000000000..df7b0a427a --- /dev/null +++ b/apps/web/components/quick-chat/use-quick-chat-tab-order.test.ts @@ -0,0 +1,200 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { QuickChatSession, QuickTerminalTab } from "@/lib/state/slices/ui/types"; + +const mockUpdateUserSettings = vi.fn(); +let mockAppState: ReturnType; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: ReturnType) => unknown) => + selector(mockAppState), + useAppStoreApi: () => ({ getState: () => mockAppState }), +})); + +vi.mock("@/lib/api/domains/settings-api", () => ({ + updateUserSettings: (...args: unknown[]) => mockUpdateUserSettings(...args), +})); + +import { useQuickChatTabOrder } from "./use-quick-chat-tab-order"; + +const WORKSPACE_ID = "workspace-1"; +const CHAT_TAB_REFERENCE = "conversation:chat-1"; +const TERMINAL_TAB_REFERENCE = "terminal:terminal-1"; +const sessions: QuickChatSession[] = [ + { sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat" }, +]; +const terminalTabs: QuickTerminalTab[] = [ + { + tabId: "terminal-1", + workspaceId: WORKSPACE_ID, + sessionId: null, + sequence: 1, + status: "running", + }, +]; + +function makeAppState() { + const appState = { + userSettings: { + quickChatTabOrderByWorkspace: {} as Record, + revision: null as number | null, + }, + quickChat: { + tabOrderByWorkspace: {} as Record, + tabOrderSyncErrorByWorkspace: {} as Record, + tabOrderSyncPendingByWorkspace: {} as Record, + }, + setQuickChatTabOrder: vi.fn((workspaceId: string, order: string[]) => { + appState.quickChat.tabOrderByWorkspace[workspaceId] = [...order]; + }), + setQuickChatTabOrderSyncState: vi.fn(), + clearQuickChatTabOrder: vi.fn((workspaceId: string, expectedOrder: string[]) => { + const current = appState.quickChat.tabOrderByWorkspace[workspaceId]; + if ( + current && + current.length === expectedOrder.length && + current.every((reference, index) => reference === expectedOrder[index]) + ) { + delete appState.quickChat.tabOrderByWorkspace[workspaceId]; + } + }), + setUserSettings: vi.fn((settings: typeof appState.userSettings) => { + appState.userSettings = settings; + }), + }; + return appState; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockAppState = makeAppState(); + mockUpdateUserSettings.mockResolvedValue({}); +}); + +describe("useQuickChatTabOrder", () => { + it("serializes saves and sends the latest order after an earlier save settles", async () => { + let releaseFirst!: () => void; + const firstSave = new Promise((resolve) => { + releaseFirst = resolve; + }); + mockUpdateUserSettings.mockReturnValueOnce(firstSave).mockResolvedValue(undefined); + + const { result } = renderHook(() => useQuickChatTabOrder(WORKSPACE_ID, sessions, terminalTabs)); + + act(() => { + result.current.persistOrder([TERMINAL_TAB_REFERENCE, CHAT_TAB_REFERENCE]); + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + + act(() => { + result.current.persistOrder([CHAT_TAB_REFERENCE, TERMINAL_TAB_REFERENCE]); + }); + expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1); + + releaseFirst(); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2)); + + expect(mockUpdateUserSettings).toHaveBeenNthCalledWith(2, { + quick_chat_tab_order_by_workspace: { + [WORKSPACE_ID]: [CHAT_TAB_REFERENCE, TERMINAL_TAB_REFERENCE], + }, + }); + }); + + it("keeps the optimistic order and reports the latest save failure", async () => { + mockUpdateUserSettings.mockRejectedValue(new Error("settings unavailable")); + + const { result } = renderHook(() => useQuickChatTabOrder(WORKSPACE_ID, sessions, terminalTabs)); + const order = [TERMINAL_TAB_REFERENCE, CHAT_TAB_REFERENCE]; + + act(() => result.current.persistOrder(order)); + await waitFor(() => + expect(mockAppState.setQuickChatTabOrderSyncState).toHaveBeenLastCalledWith(WORKSPACE_ID, { + pending: false, + error: "settings unavailable", + }), + ); + + expect(mockAppState.setQuickChatTabOrder).toHaveBeenCalledWith(WORKSPACE_ID, order); + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + quick_chat_tab_order_by_workspace: { [WORKSPACE_ID]: order }, + }); + }); + + it("reveals a newer persisted order after the latest local save succeeds", async () => { + const localOrder = [TERMINAL_TAB_REFERENCE, CHAT_TAB_REFERENCE]; + const newerServerOrder = [CHAT_TAB_REFERENCE, TERMINAL_TAB_REFERENCE]; + mockUpdateUserSettings.mockResolvedValue({ + settings: { + user_id: "user-1", + workspace_id: "", + repository_ids: [], + quick_chat_tab_order_by_workspace: { [WORKSPACE_ID]: localOrder }, + revision: 1, + updated_at: "2026-08-27T00:00:00Z", + }, + }); + + const { result, rerender } = renderHook(() => + useQuickChatTabOrder(WORKSPACE_ID, sessions, terminalTabs), + ); + + act(() => result.current.persistOrder(localOrder)); + await waitFor(() => expect(mockAppState.setUserSettings).toHaveBeenCalledOnce()); + + // A newer user.settings.updated event arrives after the local response. + mockAppState.userSettings = { + ...mockAppState.userSettings, + quickChatTabOrderByWorkspace: { [WORKSPACE_ID]: newerServerOrder }, + revision: 2, + }; + rerender(); + + expect(result.current.order).toEqual(newerServerOrder); + }); + + it("persists removal of a closed tab reference", async () => { + const savedOrder = [CHAT_TAB_REFERENCE, TERMINAL_TAB_REFERENCE]; + mockAppState.userSettings.quickChatTabOrderByWorkspace = { + [WORKSPACE_ID]: savedOrder, + }; + + const { result } = renderHook(() => useQuickChatTabOrder(WORKSPACE_ID, sessions, terminalTabs)); + + act(() => result.current.removeTabReference(CHAT_TAB_REFERENCE)); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledOnce()); + + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + quick_chat_tab_order_by_workspace: { + [WORKSPACE_ID]: [TERMINAL_TAB_REFERENCE], + }, + }); + }); +}); + +describe("useQuickChatTabOrder — workspace isolation", () => { + it("does not resend another workspace's failed optimistic order", async () => { + const otherWorkspaceId = "workspace-2"; + const persistedOtherOrder = ["conversation:other-server"]; + const failedOtherOrder = ["terminal:other-failed"]; + mockAppState.userSettings.quickChatTabOrderByWorkspace = { + [otherWorkspaceId]: persistedOtherOrder, + }; + mockAppState.quickChat.tabOrderByWorkspace = { + [otherWorkspaceId]: failedOtherOrder, + }; + + const localOrder = [TERMINAL_TAB_REFERENCE, CHAT_TAB_REFERENCE]; + const { result } = renderHook(() => useQuickChatTabOrder(WORKSPACE_ID, sessions, terminalTabs)); + + act(() => result.current.persistOrder(localOrder)); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledOnce()); + + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + quick_chat_tab_order_by_workspace: { + [WORKSPACE_ID]: localOrder, + [otherWorkspaceId]: persistedOtherOrder, + }, + }); + }); +}); diff --git a/apps/web/components/quick-chat/use-quick-chat-tab-order.ts b/apps/web/components/quick-chat/use-quick-chat-tab-order.ts new file mode 100644 index 0000000000..715fb71f34 --- /dev/null +++ b/apps/web/components/quick-chat/use-quick-chat-tab-order.ts @@ -0,0 +1,128 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { createQueuedUserSettingsSyncWithResponse } from "@/lib/user-settings-sync"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { orderQuickChatTabs } from "@/lib/state/slices/ui/quick-chat-tab-order"; +import type { QuickChatSession, QuickTerminalTab } from "@/lib/state/slices/ui/types"; + +export const conversationTabReference = (sessionId: string) => `conversation:${sessionId}`; +export const terminalTabReference = (tabId: string) => `terminal:${tabId}`; + +export function adjacentQuickChatTabReference( + order: readonly string[], + reference: string, +): string | undefined { + const index = order.indexOf(reference); + if (index < 0) return undefined; + return order[index + 1] ?? order[index - 1]; +} + +type QuickChatTabOrderActions = { + setQuickChatTabOrder: (workspaceId: string, order: string[]) => void; + clearQuickChatTabOrder: (workspaceId: string, expectedOrder: string[]) => void; + setQuickChatTabOrderSyncState: ( + workspaceId: string, + state: { pending: boolean; error: string | null }, + ) => void; +}; + +const syncQuickChatTabOrder = createQueuedUserSettingsSyncWithResponse>( + (orderByWorkspace) => ({ + quick_chat_tab_order_by_workspace: orderByWorkspace, + }), +); + +function cloneTabOrderMap(source: Record | undefined) { + return Object.fromEntries( + Object.entries(source ?? {}).map(([workspaceId, order]) => [workspaceId, [...order]]), + ); +} + +/** Provides mixed-tab ordering and a serialized user-settings save queue. */ +export function useQuickChatTabOrder( + workspaceId: string, + sessions: QuickChatSession[], + terminalTabs: QuickTerminalTab[], +) { + const appStore = useAppStoreApi(); + const state = useAppStore( + useShallow((store) => ({ + persistedOrder: store.userSettings.quickChatTabOrderByWorkspace[workspaceId], + optimisticOrder: store.quickChat.tabOrderByWorkspace[workspaceId], + syncError: store.quickChat.tabOrderSyncErrorByWorkspace[workspaceId] ?? null, + syncPending: store.quickChat.tabOrderSyncPendingByWorkspace[workspaceId] ?? false, + setQuickChatTabOrder: store.setQuickChatTabOrder, + clearQuickChatTabOrder: store.clearQuickChatTabOrder, + setQuickChatTabOrderSyncState: store.setQuickChatTabOrderSyncState, + })), + ) as { + persistedOrder: string[] | undefined; + optimisticOrder: string[] | undefined; + syncError: string | null; + syncPending: boolean; + } & QuickChatTabOrderActions; + const saveVersion = useRef(0); + + const ordered = useMemo(() => { + return orderQuickChatTabs( + sessions, + terminalTabs, + state.optimisticOrder ?? state.persistedOrder, + ); + }, [sessions, state.optimisticOrder, state.persistedOrder, terminalTabs]); + + const persistOrder = useCallback( + (order: string[]) => { + const nextOrder = [...order]; + const current = appStore.getState(); + const nextMap = cloneTabOrderMap(current.userSettings.quickChatTabOrderByWorkspace); + nextMap[workspaceId] = nextOrder; + state.setQuickChatTabOrder(workspaceId, nextOrder); + state.setQuickChatTabOrderSyncState(workspaceId, { pending: true, error: null }); + + const version = ++saveVersion.current; + void syncQuickChatTabOrder(nextMap) + .then((response) => { + const currentState = appStore.getState(); + currentState.setUserSettings( + mapUserSettingsResponse(response, currentState.userSettings), + ); + if (version === saveVersion.current) { + state.clearQuickChatTabOrder(workspaceId, nextOrder); + state.setQuickChatTabOrderSyncState(workspaceId, { pending: false, error: null }); + } + }) + .catch((error: unknown) => { + if (version !== saveVersion.current) return; + state.setQuickChatTabOrderSyncState(workspaceId, { + pending: false, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, + [appStore, state, workspaceId], + ); + + const removeTabReference = useCallback( + (reference: string) => { + const current = appStore.getState(); + const currentOrder = + current.quickChat.tabOrderByWorkspace[workspaceId] ?? + current.userSettings.quickChatTabOrderByWorkspace[workspaceId] ?? + ordered.order; + persistOrder(currentOrder.filter((item) => item !== reference)); + }, + [appStore, ordered.order, persistOrder, workspaceId], + ); + + return { + ...ordered, + persistOrder, + removeTabReference, + syncError: state.syncError, + syncPending: state.syncPending, + }; +} diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts new file mode 100644 index 0000000000..05832e5875 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts @@ -0,0 +1,123 @@ +import { type Locator, type Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { quickChatTabReferences, startQuickChatFromSetup } from "./quick-chat-helpers"; + +async function openMobileQuickChat(page: Page): Promise { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByTestId("mobile-quick-chat-button").tap(); + const dialog = page.getByRole("dialog", { name: "Quick Chat" }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + return dialog; +} + +async function closeSurvivingQuickChatTerminals(page: Page) { + const dialog = page.getByRole("dialog", { name: "Quick Chat" }); + if (!(await dialog.isVisible().catch(() => false))) return; + + const terminals = dialog.getByTestId("quick-terminal-tab"); + for (let attempts = 0; attempts < 8; attempts += 1) { + const count = await terminals.count(); + if (count === 0) return; + const tab = terminals.nth(count - 1); + const closeLabel = await tab.getAttribute("data-terminal-sequence"); + if (closeLabel) { + await tab.getByRole("button", { name: `Actions for Terminal ${closeLabel}` }).tap(); + await page.getByRole("menuitem", { name: `Close Terminal ${closeLabel}` }).tap(); + } + await expect(terminals) + .toHaveCount(count - 1, { timeout: 10_000 }) + .catch(() => undefined); + } +} + +test.describe("mobile quick chat tabs", () => { + test("moves mixed tabs, exposes rename, and keeps touch targets in the viewport", async ({ + testPage, + }) => { + test.setTimeout(120_000); + const dialog = await openMobileQuickChat(testPage); + expect(await testPage.evaluate(() => window.matchMedia("(pointer: coarse)").matches)).toBe( + true, + ); + + try { + const firstStart = testPage.waitForResponse( + (response) => + response.url().includes("/quick-chat") && response.request().method() === "POST", + ); + await startQuickChatFromSetup(dialog, testPage); + const first = (await (await firstStart).json()) as { session_id: string }; + const firstReference = `conversation:${first.session_id}`; + + // Add a terminal before the second conversation. The stable baseline + // keeps conversations first until the user chooses a mixed order. + await dialog.getByTestId("quick-chat-add-menu-trigger").tap(); + await testPage.getByTestId("quick-chat-new-terminal").tap(); + await expect(dialog.getByTestId("quick-terminal-tab")).toHaveCount(1, { + timeout: 15_000, + }); + await expect(dialog.getByTestId("quick-terminal-terminal")).toBeVisible({ timeout: 15_000 }); + const terminalSortable = dialog.locator( + '[data-testid="quick-chat-sortable-tab"][data-tab-reference^="terminal:"]', + ); + const terminalReference = await terminalSortable.getAttribute("data-tab-reference"); + if (!terminalReference) throw new Error("quick terminal reference was not rendered"); + + await dialog.getByTestId("quick-chat-add-menu-trigger").tap(); + await testPage.getByTestId("quick-chat-new-agent").tap(); + const secondStart = testPage.waitForResponse( + (response) => + response.url().includes("/quick-chat") && response.request().method() === "POST", + ); + await startQuickChatFromSetup(dialog, testPage); + const second = (await (await secondStart).json()) as { session_id: string }; + const secondReference = `conversation:${second.session_id}`; + + await expect + .poll(() => quickChatTabReferences(dialog), { timeout: 15_000 }) + .toEqual([firstReference, secondReference, terminalReference]); + + const terminalTab = dialog.getByTestId("quick-terminal-tab"); + await terminalTab.getByRole("button", { name: "Actions for Terminal 1" }).tap(); + await testPage.getByRole("menuitem", { name: "Move Terminal 1 left" }).tap(); + await expect + .poll(() => quickChatTabReferences(dialog), { timeout: 15_000 }) + .toEqual([firstReference, terminalReference, secondReference]); + + const firstTab = dialog.locator( + `[data-tab-reference="${firstReference}"] [data-testid="quick-chat-tab"]`, + ); + await firstTab.getByRole("button", { name: /^Actions for / }).tap(); + await testPage.getByRole("menuitem", { name: "Rename" }).tap(); + const renameInput = firstTab.getByRole("textbox", { name: "Rename chat" }); + await expect(renameInput).toBeVisible(); + await renameInput.fill("Phone renamed"); + await renameInput.press("Enter"); + await expect(firstTab.getByTestId("quick-chat-tab-name")).toHaveText("Phone renamed"); + + const controls = dialog.locator( + '[data-testid="quick-chat-tab"] button, [data-testid="quick-terminal-tab"] button', + ); + const sizes = await controls.evaluateAll((buttons) => + buttons.map((button) => { + const rect = button.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }), + ); + expect(sizes.length).toBeGreaterThan(0); + for (const size of sizes) { + expect(size.width).toBeGreaterThanOrEqual(44); + expect(size.height).toBeGreaterThanOrEqual(44); + } + await assertNoDocumentHorizontalOverflow(testPage, "mobile mixed Quick Chat tabs"); + + await terminalTab.getByRole("button", { name: "Actions for Terminal 1" }).tap(); + await testPage.getByRole("menuitem", { name: "Close Terminal 1" }).tap(); + await expect(dialog.getByTestId("quick-terminal-tab")).toHaveCount(0); + } finally { + await closeSurvivingQuickChatTerminals(testPage); + } + }); +}); diff --git a/apps/web/e2e/tests/chat/quick-chat-helpers.ts b/apps/web/e2e/tests/chat/quick-chat-helpers.ts index b40aee1728..c87ce319bd 100644 --- a/apps/web/e2e/tests/chat/quick-chat-helpers.ts +++ b/apps/web/e2e/tests/chat/quick-chat-helpers.ts @@ -18,6 +18,16 @@ const SETTLED_SESSION_STATES = new Set([ ]); const ACTIVE_SESSION_STATES = new Set(["STARTING", "RUNNING"]); +export function quickChatTabReferences(dialog: Locator): Promise { + return dialog + .getByTestId("quick-chat-sortable-tab") + .evaluateAll((tabs) => + tabs + .map((tab) => tab.getAttribute("data-tab-reference")) + .filter((reference): reference is string => Boolean(reference)), + ); +} + /** * Establish a backend-confirmed settle point before arming waits for the next * turn. The WS watcher does not replay notifications, so the startup settle diff --git a/apps/web/e2e/tests/chat/quick-chat.spec.ts b/apps/web/e2e/tests/chat/quick-chat.spec.ts index 154f738d2d..5bf1a9d782 100644 --- a/apps/web/e2e/tests/chat/quick-chat.spec.ts +++ b/apps/web/e2e/tests/chat/quick-chat.spec.ts @@ -1,4 +1,4 @@ -import { type Locator } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { test, expect } from "../../fixtures/test-base"; @@ -6,6 +6,7 @@ import { attachAvailableCommandsCapture } from "../../helpers/ws-capture"; import { openQuickChatSetup, openQuickChatWithAgent, + quickChatTabReferences, selectAgentIfNeeded, sendQuickChatMessage, startQuickChatFromSetup, @@ -28,6 +29,29 @@ async function waitForQuickChatWidth(dialog: Locator) { .toBeLessThan(2); } +async function dragQuickChatTab(page: Page, source: Locator, target: Locator) { + const [sourceBox, targetBox] = await Promise.all([ + source + .locator('[data-testid="quick-chat-tab"], [data-testid="quick-terminal-tab"]') + .first() + .boundingBox(), + target.boundingBox(), + ]); + expect(sourceBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + if (!sourceBox || !targetBox) throw new Error("quick chat tab geometry unavailable"); + + const sourceX = sourceBox.x + sourceBox.width / 2; + const sourceY = sourceBox.y + sourceBox.height / 2; + await page.mouse.move(sourceX, sourceY); + await page.mouse.down(); + await page.mouse.move(sourceX + 16, sourceY); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { + steps: 8, + }); + await page.mouse.up(); +} + test.describe("Quick Chat", () => { test("adds elevation when opened over the page", async ({ testPage }) => { const dialog = await openQuickChatSetup(testPage); @@ -191,7 +215,7 @@ test.describe("Quick Chat", () => { // The tab bar sits outside quick-chat-content (the clarification's // shortcut scope), e.g. the resize handles or a mouse-focused tab. await dialog - .locator('[data-testid="quick-chat-tab"] button:not([aria-label^="Close"])') + .locator('[data-testid="quick-chat-tab"] button[title="Double-click to rename"]') .focus(); await testPage.keyboard.press("Escape"); @@ -344,7 +368,9 @@ test.describe("Quick Chat", () => { const configTab = dialog .getByTestId("quick-chat-tab") .filter({ has: testPage.getByRole("img", { name: "Configuration chat" }) }); - await configTab.getByRole("button").first().click(); + await configTab + .getByRole("button", { name: /Configuration chat \/e2e:simple-message/ }) + .click(); await expect(configTab).toHaveClass(/bg-background/); await dialog.getByTestId("quick-chat-add-menu-trigger").click(); @@ -668,7 +694,28 @@ test.describe("Quick Chat", () => { }); }); - test("restores multiple chat tabs in newest-activity order after reload", async ({ + test("exposes tab actions on a coarse-pointer touch tablet", async ({ tabletTestPage }) => { + expect(await tabletTestPage.evaluate(() => window.innerWidth)).toBe(900); + expect( + await tabletTestPage.evaluate(() => window.matchMedia("(pointer: coarse)").matches), + ).toBe(true); + + const dialog = await openQuickChatWithAgent(tabletTestPage); + const actionTrigger = dialog.getByRole("button", { name: /^Actions for / }); + await expect(actionTrigger).toBeVisible(); + await expect(actionTrigger).toHaveClass(/h-11/); + await expect(actionTrigger).toHaveClass(/w-11/); + + await actionTrigger.tap(); + await expect( + tabletTestPage.getByRole("menuitem", { name: "Rename", exact: true }), + ).toBeVisible(); + await expect(tabletTestPage.getByRole("menuitem", { name: /^Move .* left$/ })).toBeVisible(); + await expect(tabletTestPage.getByRole("menuitem", { name: /^Move .* right$/ })).toBeVisible(); + await expect(tabletTestPage.getByRole("menuitem", { name: /^Close .*$/ })).toBeVisible(); + }); + + test("preserves mixed tab order, generated title, and rename after reload", async ({ testPage, apiClient, seedData, @@ -701,34 +748,87 @@ test.describe("Quick Chat", () => { ) .toBe(quickChatWorkspace.id); - const dialog = await openQuickChatWithAgent(testPage, false); + await apiClient.saveUserSettings({ agent_generated_task_titles: true }); + const dialog = await openQuickChatSetup(testPage, false); - // Send a message in the first tab. - await sendQuickChatMessage(dialog, testPage, 'e2e:message("first tab response")'); - await expect(dialog.getByText("first tab response", { exact: true })).toBeVisible({ + const firstStart = testPage.waitForResponse( + (response) => + response.url().includes("/quick-chat") && response.request().method() === "POST", + ); + await startQuickChatFromSetup(dialog, testPage); + const first = (await (await firstStart).json()) as { session_id: string; task_id: string }; + const firstReference = `conversation:${first.session_id}`; + + // The deterministic script calls the real title MCP endpoint through the + // mock agent, then the task.updated event relabels the open tab. + const generatedTitle = "Investigate cache invalidation"; + await sendQuickChatMessage( + dialog, + testPage, + `e2e:mcp:kandev:set_task_title_kandev(${JSON.stringify({ title: generatedTitle })})`, + ); + const firstTab = dialog.locator( + `[data-tab-reference="${firstReference}"] [data-testid="quick-chat-tab"]`, + ); + await expect(firstTab.getByTestId("quick-chat-tab-name")).toHaveText(generatedTitle, { timeout: 30_000, }); - // Create a new tab. + // Add a terminal between the first chat and the next chat. + await dialog.getByTestId("quick-chat-add-menu-trigger").click(); + await testPage.getByTestId("quick-chat-new-terminal").click(); + await expect(dialog.getByTestId("quick-terminal-tab")).toHaveCount(1, { timeout: 15_000 }); + await expect(dialog.getByTestId("quick-terminal-terminal")).toBeVisible({ timeout: 15_000 }); + const terminalSortable = dialog.locator( + '[data-testid="quick-chat-sortable-tab"][data-tab-reference^="terminal:"]', + ); + const terminalReference = await terminalSortable.getAttribute("data-tab-reference"); + if (!terminalReference) throw new Error("quick terminal reference was not rendered"); + + // Create a second chat after the terminal. The stable baseline puts all + // conversations first and terminals after them until the user reorders it. await dialog.getByTestId("quick-chat-add-menu-trigger").click(); await testPage.getByTestId("quick-chat-new-agent").click(); // Setup guidance remains visible for every new chat. await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 5_000 }); await expect(dialog.getByTestId("quick-chat-introduction")).toBeVisible(); + + const secondStart = testPage.waitForResponse( + (response) => + response.url().includes("/quick-chat") && response.request().method() === "POST", + ); await startQuickChatFromSetup(dialog, testPage); + const second = (await (await secondStart).json()) as { session_id: string; task_id: string }; + const secondReference = `conversation:${second.session_id}`; + await expect + .poll(() => quickChatTabReferences(dialog), { timeout: 15_000 }) + .toEqual([firstReference, secondReference, terminalReference]); - // Send a message in the second tab using script mode. - await sendQuickChatMessage(dialog, testPage, 'e2e:message("second tab response")'); - // The user message bubble also contains "second tab response" — match only - // the agent reply (the rendered text without the surrounding script call). - await expect(dialog.getByText("second tab response", { exact: true })).toBeVisible({ - timeout: 30_000, - }); + // Rename the second chat through the desktop context-menu path. + const secondTab = dialog.locator( + `[data-tab-reference="${secondReference}"] [data-testid="quick-chat-tab"]`, + ); + await secondTab.locator('button[title="Double-click to rename"]').click({ button: "right" }); + await testPage.getByRole("menuitem", { name: "Rename", exact: true }).click(); + const renameInput = secondTab.getByRole("textbox", { name: "Rename chat" }); + await expect(renameInput).toBeVisible(); + await renameInput.fill("Cache follow-up"); + await renameInput.press("Enter"); + await expect(secondTab.getByTestId("quick-chat-tab-name")).toHaveText("Cache follow-up"); - const originalTabs = dialog.getByTestId("quick-chat-tab"); - await expect(originalTabs).toHaveCount(2); - const originalNames = await originalTabs.locator("span").allTextContents(); + // Drag the terminal ahead of both conversations. This exercises the real + // pointer sensor and persists the mixed conversation/terminal order. + await dragQuickChatTab( + testPage, + terminalSortable, + dialog.locator( + `[data-testid="quick-chat-sortable-tab"][data-tab-reference="${firstReference}"]`, + ), + ); + await expect + .poll(() => quickChatTabReferences(dialog), { timeout: 15_000 }) + .toEqual([terminalReference, firstReference, secondReference]); const beforeReloadResponse = await apiClient.rawRequest( "GET", @@ -740,6 +840,18 @@ test.describe("Quick Chat", () => { }; expect(beforeReload.tasks).toHaveLength(2); + const terminalBeforeReloadResponse = await apiClient.rawRequest( + "GET", + `/api/v1/quick-terminal-tabs?workspace_id=${quickChatWorkspace.id}`, + ); + expect(terminalBeforeReloadResponse.ok).toBe(true); + const terminalsBeforeReload = (await terminalBeforeReloadResponse.json()) as { + tabs: Array<{ tabId: string }>; + }; + expect(terminalsBeforeReload.tabs.map((tab) => tab.tabId)).toEqual([ + terminalReference.replace("terminal:", ""), + ]); + await testPage.reload(); await testPage.waitForLoadState("networkidle"); @@ -755,20 +867,36 @@ test.describe("Quick Chat", () => { await testPage.keyboard.press(`${modifier}+Shift+q`); const restoredDialog = testPage.getByRole("dialog", { name: "Quick Chat" }); await expect(restoredDialog).toBeVisible({ timeout: 10_000 }); + await expect + .poll(() => + testPage.evaluate( + "window.__KANDEV_E2E_STORE__?.getState().quickChat.terminalTabs?.length ?? -1", + ), + ) + .toBe(1); + const restoredTerminalState = await testPage.evaluate( + "JSON.stringify(window.__KANDEV_E2E_STORE__?.getState().quickChat.terminalTabs ?? [])", + ); + expect(restoredTerminalState).toContain(terminalReference.replace("terminal:", "")); + expect(restoredTerminalState).toContain(quickChatWorkspace.id); - const restoredTabs = restoredDialog.getByTestId("quick-chat-tab"); - await expect(restoredTabs).toHaveCount(2); - await expect(restoredTabs.locator("span")).toHaveText([...originalNames].reverse()); + const restoredTabs = restoredDialog.getByTestId("quick-chat-sortable-tab"); + await expect(restoredTabs).toHaveCount(3); + await expect + .poll(() => quickChatTabReferences(restoredDialog), { timeout: 15_000 }) + .toEqual([terminalReference, firstReference, secondReference]); + await expect( + restoredDialog.locator( + `[data-tab-reference="${firstReference}"] [data-testid="quick-chat-tab-name"]`, + ), + ).toHaveText(generatedTitle); + await expect( + restoredDialog.locator( + `[data-tab-reference="${secondReference}"] [data-testid="quick-chat-tab-name"]`, + ), + ).toHaveText("Cache follow-up"); await expect(restoredDialog.getByTestId("quick-chat-setup")).not.toBeVisible(); - await restoredTabs.nth(0).locator("button").first().click(); - await expect(restoredDialog.getByText("second tab response", { exact: true })).toBeVisible({ - timeout: 10_000, - }); - - await restoredTabs.nth(1).locator("button").first().click(); - await expect(restoredDialog.getByText("first tab response", { exact: true })).toBeVisible({ - timeout: 10_000, - }); + await expect(restoredDialog.getByTestId("quick-terminal-tab")).toHaveCount(1); }); }); diff --git a/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts b/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts index 81cd4cedfb..520d573cf6 100644 --- a/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts +++ b/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts @@ -1,6 +1,7 @@ import { type Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { closeQuickTerminalTab } from "./terminal-test-helpers"; async function readQuickTerminalBuffer(page: Page): Promise { return page.evaluate(() => { @@ -44,10 +45,7 @@ async function closeSurvivingQuickTerminals(page: Page) { for (let attempts = 0; attempts < 8; attempts += 1) { const count = await tabs.count(); if (count === 0) return; - await tabs - .nth(count - 1) - .getByRole("button", { name: /^Close Terminal \d+$/ }) - .tap(); + await closeQuickTerminalTab(page, tabs.nth(count - 1)); await expect(tabs).toHaveCount(count - 1, { timeout: 10_000 }); } } @@ -84,13 +82,19 @@ test.describe("mobile quick terminal tabs", () => { await waitForTerminalReady(testPage); await sendCommand(testPage, "echo", "MOBILE_TERMINAL_ONE"); - const firstClose = dialog - .locator('[data-testid="quick-terminal-tab"][data-terminal-sequence="1"]') - .getByRole("button", { name: "Close Terminal 1" }); - const firstCloseBox = await firstClose.boundingBox(); - expect(firstCloseBox).not.toBeNull(); - expect(firstCloseBox!.width).toBeGreaterThanOrEqual(44); - expect(firstCloseBox!.height).toBeGreaterThanOrEqual(44); + const firstTab = dialog.locator( + '[data-testid="quick-terminal-tab"][data-terminal-sequence="1"]', + ); + const firstActions = firstTab.getByRole("button", { name: "Actions for Terminal 1" }); + const firstActionsBox = await firstActions.boundingBox(); + expect(firstActionsBox).not.toBeNull(); + expect(firstActionsBox!.width).toBeGreaterThanOrEqual(44); + expect(firstActionsBox!.height).toBeGreaterThanOrEqual(44); + await firstActions.tap(); + await expect( + testPage.getByRole("menuitem", { name: "Close Terminal 1", exact: true }), + ).toBeVisible(); + await testPage.keyboard.press("Escape"); // A mobile reload restores the durable descriptor and reattaches the // detached PTY before the user creates any additional terminal. @@ -161,10 +165,10 @@ test.describe("mobile quick terminal tabs", () => { await assertNoDocumentHorizontalOverflow(testPage, "mobile shared terminal tabs"); // Explicit close stops one sibling and returns to the first tab. - await dialog - .locator('[data-testid="quick-terminal-tab"][data-terminal-sequence="2"]') - .getByRole("button", { name: "Close Terminal 2" }) - .tap(); + await closeQuickTerminalTab( + testPage, + dialog.locator('[data-testid="quick-terminal-tab"][data-terminal-sequence="2"]'), + ); await expect(dialog.locator('[data-testid="quick-terminal-tab"]')).toHaveCount(1); await expect.poll(() => readQuickTerminalBuffer(testPage)).toContain("MOBILE_TERMINAL_ONE"); diff --git a/apps/web/e2e/tests/terminal/quick-terminal.spec.ts b/apps/web/e2e/tests/terminal/quick-terminal.spec.ts index 510c92b617..0dc893c0b7 100644 --- a/apps/web/e2e/tests/terminal/quick-terminal.spec.ts +++ b/apps/web/e2e/tests/terminal/quick-terminal.spec.ts @@ -1,6 +1,7 @@ import { type Locator, type Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; import { assertLocatorWithinViewportX } from "../../helpers/layout-assertions"; +import { closeQuickTerminalTab } from "./terminal-test-helpers"; const QUICK_CHAT_TITLE = "Quick Chat"; @@ -50,10 +51,7 @@ async function closeSurvivingQuickTerminals(page: Page, launcherTestId: string) for (let attempts = 0; attempts < 8; attempts += 1) { const count = await tabs.count(); if (count === 0) return; - await tabs - .nth(count - 1) - .getByRole("button", { name: /^Close Terminal \d+$/ }) - .click(); + await closeQuickTerminalTab(page, tabs.nth(count - 1)); await expect(tabs).toHaveCount(count - 1, { timeout: 10_000 }); } } @@ -144,7 +142,7 @@ test.describe("quick terminal tabs", () => { await runCommandAndWaitForOutput(testPage, "echo $KANDEV_QT_TWO", "QUICK_TERMINAL_TWO"); // Closing one tab stops/removes only that tab and falls back to its sibling. - await secondTab.getByRole("button", { name: "Close Terminal 2" }).click(); + await closeQuickTerminalTab(testPage, secondTab); await expect(dialog.locator('[data-testid="quick-terminal-tab"]')).toHaveCount(1); await runCommandAndWaitForOutput(testPage, "echo $KANDEV_QT_ONE", "QUICK_TERMINAL_ONE"); diff --git a/apps/web/e2e/tests/terminal/terminal-test-helpers.ts b/apps/web/e2e/tests/terminal/terminal-test-helpers.ts index a22e6eb10f..966d6c7cc0 100644 --- a/apps/web/e2e/tests/terminal/terminal-test-helpers.ts +++ b/apps/web/e2e/tests/terminal/terminal-test-helpers.ts @@ -1,4 +1,4 @@ -import { expect, type Locator } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; export type TerminalThemeSnapshot = { background?: string; @@ -98,3 +98,22 @@ export async function readTerminalHostTheme(host: Locator): Promise { + const sequence = await tab.getAttribute("data-terminal-sequence"); + if (!sequence) throw new Error("Quick terminal tab is missing its sequence"); + + const closeButton = tab.getByRole("button", { name: `Close Terminal ${sequence}` }); + if (await closeButton.isVisible().catch(() => false)) { + await closeButton.click(); + return; + } + + await tab.getByRole("button", { name: `Actions for Terminal ${sequence}` }).tap(); + await page.getByRole("menuitem", { name: `Close Terminal ${sequence}`, exact: true }).tap(); +} diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 0e2bbe6d08..35ad601bbd 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -103,6 +103,7 @@ function makeUnloadedSettings(): UserSettingsState { systemMetricsDisplay: { showInTopbar: false, simplified: false }, appStatusBarEnabled: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, + quickChatTabOrderByWorkspace: {}, lspAutoStartLanguages: [], lspAutoInstallLanguages: [], lspServerConfigs: {}, diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts index 87d126df59..120eb45337 100644 --- a/apps/web/lib/api/domains/workspace-api.ts +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -264,6 +264,7 @@ type StartQuickChatCommon = { agent_profile_id?: string; executor_id?: string; prompt?: string; + auto_title?: boolean; }; type StartQuickChatLegacyRepository = { diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 8826555436..18f4ac10c7 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -134,6 +134,23 @@ describe("app status bar visibility hydration", () => { }); }); +describe("quick-chat tab order hydration", () => { + it("maps the per-workspace mixed-tab order and keeps omitted patches unchanged", () => { + const order = { + "workspace-a": ["conversation:one", "terminal:one"], + }; + expect( + buildCoreFields({ quick_chat_tab_order_by_workspace: order }).quickChatTabOrderByWorkspace, + ).toEqual(order); + + const current = { + ...mapUserSettingsResponse(null), + quickChatTabOrderByWorkspace: order, + }; + expect(buildCoreFields({}, current).quickChatTabOrderByWorkspace).toEqual(order); + }); +}); + describe("buildCoreFields", () => { it("normalizes the simplified metrics preference and defaults old rows to detailed", () => { expect(parseSystemMetricsDisplay({ show_in_topbar: true, simplified: true } as never)).toEqual({ diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 908f3ce980..340da5b1e2 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -83,6 +83,7 @@ export function createDefaultUserSettings(): UserSettingsState { systemMetricsDisplay: { showInTopbar: false, simplified: false }, appStatusBarEnabled: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, + quickChatTabOrderByWorkspace: {}, hiddenWorkflowStepIds: {}, workflowIdsWithAutoHideEmptySteps: [], loaded: false, @@ -334,6 +335,8 @@ export function buildCoreFields( parseAppStatusBarOrder, ), appStatusBarEnabled: s.app_status_bar_enabled ?? current.appStatusBarEnabled, + quickChatTabOrderByWorkspace: + s.quick_chat_tab_order_by_workspace ?? current.quickChatTabOrderByWorkspace, hiddenWorkflowStepIds: s.kanban_hidden_step_ids ?? current.hiddenWorkflowStepIds, workflowIdsWithAutoHideEmptySteps: s.workflow_ids_with_auto_hide_empty_steps ?? current.workflowIdsWithAutoHideEmptySteps, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 415b7b08a2..dd946e954f 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -437,6 +437,7 @@ export type UserSettingsState = { systemMetricsDisplay: { showInTopbar: boolean; simplified: boolean }; appStatusBarEnabled: boolean; appStatusBarOrder: AppStatusBarOrderState; + quickChatTabOrderByWorkspace: Record; hiddenWorkflowStepIds: Record; workflowIdsWithAutoHideEmptySteps: string[]; loaded: boolean; diff --git a/apps/web/lib/state/slices/ui/quick-chat-actions.ts b/apps/web/lib/state/slices/ui/quick-chat-actions.ts index f267e18751..8bddea12e3 100644 --- a/apps/web/lib/state/slices/ui/quick-chat-actions.ts +++ b/apps/web/lib/state/slices/ui/quick-chat-actions.ts @@ -119,8 +119,38 @@ function addQuickChatSession(set: ImmerSet) { }); } +function buildQuickChatOrderActions(set: ImmerSet) { + return { + setQuickChatTabOrder: (workspaceId: string, order: string[]) => + set((draft) => { + draft.quickChat.tabOrderByWorkspace[workspaceId] = [...order]; + }), + clearQuickChatTabOrder: (workspaceId: string, expectedOrder: string[]) => + set((draft) => { + const current = draft.quickChat.tabOrderByWorkspace[workspaceId]; + if ( + !current || + current.length !== expectedOrder.length || + current.some((reference, index) => reference !== expectedOrder[index]) + ) { + return; + } + delete draft.quickChat.tabOrderByWorkspace[workspaceId]; + }), + setQuickChatTabOrderSyncState: ( + workspaceId: string, + state: { pending: boolean; error: string | null }, + ) => + set((draft) => { + draft.quickChat.tabOrderSyncPendingByWorkspace[workspaceId] = state.pending; + draft.quickChat.tabOrderSyncErrorByWorkspace[workspaceId] = state.error; + }), + }; +} + export function buildQuickChatActions(set: ImmerSet) { return { + ...buildQuickChatOrderActions(set), addQuickChatSession: addQuickChatSession(set), openQuickChat: openQuickChat(set), closeQuickChat: () => diff --git a/apps/web/lib/state/slices/ui/quick-chat-sync.test.ts b/apps/web/lib/state/slices/ui/quick-chat-sync.test.ts index fc8f545da0..76efb7aa5c 100644 --- a/apps/web/lib/state/slices/ui/quick-chat-sync.test.ts +++ b/apps/web/lib/state/slices/ui/quick-chat-sync.test.ts @@ -45,6 +45,9 @@ function state(sessions: QuickChatSession[], overrides: Partial sessionOwnership: {}, syncRevisionByWorkspace: {}, tombstonedSessions: {}, + tabOrderByWorkspace: {}, + tabOrderSyncErrorByWorkspace: {}, + tabOrderSyncPendingByWorkspace: {}, ...overrides, }; } diff --git a/apps/web/lib/state/slices/ui/quick-chat-tab-order.test.ts b/apps/web/lib/state/slices/ui/quick-chat-tab-order.test.ts new file mode 100644 index 0000000000..e9d4dcb105 --- /dev/null +++ b/apps/web/lib/state/slices/ui/quick-chat-tab-order.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { getQuickChatSetupSessionId } from "./quick-chat-session"; +import { + orderQuickChatTabs, + resolveQuickChatTabOrder, + type QuickChatTabDescriptor, +} from "./quick-chat-tab-order"; + +const OLD_CHAT_REFERENCE = "conversation:old"; +const TERMINAL_REFERENCE = "terminal:one"; +const TERMINAL_ONE_REFERENCE = "terminal:terminal-1"; + +describe("resolveQuickChatTabOrder", () => { + it("keeps known saved tabs once and appends new tabs in baseline order", () => { + const baseline: QuickChatTabDescriptor[] = [ + { reference: OLD_CHAT_REFERENCE, baselineOrder: 0 }, + { reference: TERMINAL_REFERENCE, baselineOrder: 1 }, + { reference: "conversation:new", baselineOrder: 2 }, + ]; + + expect( + resolveQuickChatTabOrder(baseline, [ + TERMINAL_REFERENCE, + TERMINAL_REFERENCE, + "conversation:missing", + OLD_CHAT_REFERENCE, + ]), + ).toEqual([TERMINAL_REFERENCE, OLD_CHAT_REFERENCE, "conversation:new"]); + }); +}); + +describe("orderQuickChatTabs", () => { + it("orders mixed tabs from saved references and keeps setup tabs trailing", () => { + const sessions = [ + { sessionId: "old", workspaceId: "ws", kind: "chat" as const }, + { sessionId: "new", workspaceId: "ws", kind: "chat" as const }, + { + sessionId: getQuickChatSetupSessionId("ws", "chat"), + workspaceId: "ws", + kind: "chat" as const, + }, + ]; + const terminalTabs = [ + { + tabId: "terminal-1", + workspaceId: "ws", + sessionId: null, + sequence: 1, + status: "running" as const, + }, + ]; + + const result = orderQuickChatTabs(sessions, terminalTabs, [ + TERMINAL_ONE_REFERENCE, + OLD_CHAT_REFERENCE, + "conversation:unknown", + ]); + + expect(result.order).toEqual([TERMINAL_ONE_REFERENCE, OLD_CHAT_REFERENCE, "conversation:new"]); + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + "old", + "new", + getQuickChatSetupSessionId("ws", "chat"), + ]); + expect(result.terminalTabs.map((tab) => tab.tabId)).toEqual(["terminal-1"]); + }); +}); diff --git a/apps/web/lib/state/slices/ui/quick-chat-tab-order.ts b/apps/web/lib/state/slices/ui/quick-chat-tab-order.ts new file mode 100644 index 0000000000..3734d56884 --- /dev/null +++ b/apps/web/lib/state/slices/ui/quick-chat-tab-order.ts @@ -0,0 +1,91 @@ +import { isQuickChatSetupSessionId } from "./quick-chat-session"; +import type { QuickChatSession, QuickTerminalTab } from "./types"; + +/** A live Quick Chat tab and its position in the stable creation baseline. */ +export type QuickChatTabDescriptor = { + reference: string; + baselineOrder: number; +}; + +export type OrderedQuickChatTabs = { + sessions: QuickChatSession[]; + terminalTabs: QuickTerminalTab[]; + order: string[]; +}; + +/** + * Resolves a saved mixed conversation/terminal order against the tabs that + * exist now. Unknown and duplicate saved references are ignored. Tabs that + * are not in the saved order are appended in the stable baseline order. + */ +export function resolveQuickChatTabOrder( + baseline: readonly QuickChatTabDescriptor[], + saved: readonly string[] | undefined, +): string[] { + const known = new Map(); + for (const descriptor of baseline) { + if (!known.has(descriptor.reference)) known.set(descriptor.reference, descriptor); + } + + const ordered: string[] = []; + const seen = new Set(); + for (const reference of saved ?? []) { + if (!known.has(reference) || seen.has(reference)) continue; + seen.add(reference); + ordered.push(reference); + } + + const baselineOrder = [...known.values()].sort( + (left, right) => + left.baselineOrder - right.baselineOrder || left.reference.localeCompare(right.reference), + ); + for (const descriptor of baselineOrder) { + if (seen.has(descriptor.reference)) continue; + seen.add(descriptor.reference); + ordered.push(descriptor.reference); + } + + return ordered; +} + +/** Resolves live Quick Chat tabs into one mixed conversation/terminal list. */ +export function orderQuickChatTabs( + sessions: QuickChatSession[], + terminalTabs: QuickTerminalTab[], + saved: string[] | undefined, +): OrderedQuickChatTabs { + const reorderableSessions = sessions.filter( + (session) => !isQuickChatSetupSessionId(session.sessionId), + ); + const setupSessions = sessions.filter((session) => isQuickChatSetupSessionId(session.sessionId)); + const descriptors: QuickChatTabDescriptor[] = [ + ...reorderableSessions.map((session, index) => ({ + reference: `conversation:${session.sessionId}`, + baselineOrder: index, + })), + ...terminalTabs.map((tab, index) => ({ + reference: `terminal:${tab.tabId}`, + baselineOrder: reorderableSessions.length + index, + })), + ]; + const order = resolveQuickChatTabOrder(descriptors, saved); + const sessionByReference = new Map( + reorderableSessions.map((session) => [`conversation:${session.sessionId}`, session]), + ); + const terminalByReference = new Map(terminalTabs.map((tab) => [`terminal:${tab.tabId}`, tab])); + + return { + order, + sessions: [ + ...order.flatMap((reference) => { + const session = sessionByReference.get(reference); + return session ? [session] : []; + }), + ...setupSessions, + ], + terminalTabs: order.flatMap((reference) => { + const tab = terminalByReference.get(reference); + return tab ? [tab] : []; + }), + }; +} diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index a6111cc7f3..03e6b58672 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -168,6 +168,10 @@ export type QuickChatState = { sessionOwnership: Record; syncRevisionByWorkspace: Record; tombstonedSessions: Record; + /** Optimistic mixed-tab order keyed by workspace until the save settles. */ + tabOrderByWorkspace: Record; + tabOrderSyncErrorByWorkspace: Record; + tabOrderSyncPendingByWorkspace: Record; }; export type SessionFailureNotification = { @@ -376,6 +380,15 @@ export type UISliceActions = { recordQuickChatSettled: (sessionId: string, updatedAt: string) => boolean; /** Removes a server-backed quick-chat session and suppresses late task events. */ removeQuickChatSession: (sessionId: string) => void; + /** Sets the optimistic mixed conversation/terminal order for a workspace. */ + setQuickChatTabOrder: (workspaceId: string, order: string[]) => void; + /** Clears a matching optimistic order after its authoritative save succeeds. */ + clearQuickChatTabOrder: (workspaceId: string, expectedOrder: string[]) => void; + /** Updates the pending/error state for a workspace order save. */ + setQuickChatTabOrderSyncState: ( + workspaceId: string, + state: { pending: boolean; error: string | null }, + ) => void; setQuickChatInitialPrompt: (sessionId: string, prompt?: string) => void; setSessionFailureNotification: (n: SessionFailureNotification | null) => void; setTaskDeletedNotification: (n: TaskDeletedNotification | null) => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 84d8e5dce8..78d1be0d0b 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -131,6 +131,9 @@ export const defaultUIState: UISliceState = { sessionOwnership: {}, syncRevisionByWorkspace: {}, tombstonedSessions: {}, + tabOrderByWorkspace: {}, + tabOrderSyncErrorByWorkspace: {}, + tabOrderSyncPendingByWorkspace: {}, }, sessionFailureNotification: null, taskDeletedNotification: null, diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 586db0101c..0b6093a90f 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -386,6 +386,9 @@ export type AppState = KanbanSlice & { clearQuickChatUnseenIdle: UIA["clearQuickChatUnseenIdle"]; recordQuickChatSettled: UIA["recordQuickChatSettled"]; removeQuickChatSession: UIA["removeQuickChatSession"]; + setQuickChatTabOrder: UIA["setQuickChatTabOrder"]; + clearQuickChatTabOrder: UIA["clearQuickChatTabOrder"]; + setQuickChatTabOrderSyncState: UIA["setQuickChatTabOrderSyncState"]; closeQuickChat: () => void; closeQuickChatSession: (sessionId: string) => void; setActiveQuickChatSession: (sessionId: string, workspaceId: string) => void; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index b55f1c968a..b3c011eade 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -114,6 +114,7 @@ export type UserSettings = { system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; app_status_bar_enabled?: boolean; app_status_bar_order?: AppStatusBarOrderApi; + quick_chat_tab_order_by_workspace?: Record; kanban_hidden_step_ids?: Record; workflow_ids_with_auto_hide_empty_steps?: string[]; revision?: number; @@ -180,6 +181,7 @@ export type UserSettingsUpdatePayload = { system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; app_status_bar_enabled?: boolean; app_status_bar_order?: AppStatusBarOrderApi; + quick_chat_tab_order_by_workspace?: Record; kanban_hidden_step_ids?: Record; workflow_ids_with_auto_hide_empty_steps?: string[]; }; diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json index 8de0d9a4bc..7e9b92cc02 100644 --- a/apps/web/src/locales/en/chat.json +++ b/apps/web/src/locales/en/chat.json @@ -51,6 +51,10 @@ "failedToSendQueuedMessageNow": "Failed to send the queued message now.", "mergeReferenceOverflow": "Too many entity references to merge these queued messages.", "mergeWithAbove": "Merge with above", + "moveQuickChatTabLeft": "Move {{name}} left", + "moveQuickChatTabRight": "Move {{name}} right", + "quickChatTabActions": "Actions for {{name}}", + "reorderQuickChatTab": "Reorder {{name}}", "noTodosYet": "No todos yet.", "newChatTab": "New Chat", "newQuickChat": "New Quick Chat", @@ -101,6 +105,8 @@ "quickChatDialogIntro": "Start a quick conversation with an agent without creating a formal task.", "quickChatIntro": "Chat with an agent about an idea, question, or codebase.", "quickChatIntroSecondary": "Quick chats stay outside your task board.", + "quickChatTabOrderSaveFailed": "Could not save Quick Chat tab order.", + "quickChatTabOrderSaveFailedDescription": "Your current order is kept here. Reload to restore the last saved order.", "removeQueuedMessage": "Remove queued message", "renameChat": "Rename chat", "reorderQueuedMessage": "Reorder queued message", diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json index 7faf6f9f55..21ec2d4516 100644 --- a/apps/web/src/locales/pseudo/chat.json +++ b/apps/web/src/locales/pseudo/chat.json @@ -51,6 +51,10 @@ "failedToSendQueuedMessageNow": "Ƒàĩĺēď ţō śēńď ţĥē qũēũēď ḿēśśàĝē ńōŵ.", "mergeReferenceOverflow": "Ţōō ḿàńŷ ēńţĩţŷ ŕēƒēŕēńćēś ţō ḿēŕĝē ţĥēśē qũēũēď ḿēśśàĝēś.", "mergeWithAbove": "Ḿēŕĝē ŵĩţĥ àƀōvē", + "moveQuickChatTabLeft": "Ḿōvē {{name}} ĺēƒţ", + "moveQuickChatTabRight": "Ḿōvē {{name}} ŕĩĝĥţ", + "quickChatTabActions": "Àćţĩōńś ƒōŕ {{name}}", + "reorderQuickChatTab": "Ŕēōŕďēŕ {{name}}", "noTodosYet": "Ńō ţōďōś ŷēţ.", "newChatTab": "Ńēŵ Ćĥàţ", "newQuickChat": "Ńēŵ Qũĩćķ Ćĥàţ", @@ -101,6 +105,8 @@ "quickChatDialogIntro": "Śţàŕţ à qũĩćķ ćōńvēŕśàţĩōń ŵĩţĥ àń àĝēńţ ŵĩţĥōũţ ćŕēàţĩńĝ à ƒōŕḿàĺ ţàśķ.", "quickChatIntro": "Ćĥàţ ŵĩţĥ àń àĝēńţ àƀōũţ àń ĩďēà, qũēśţĩōń, ōŕ ćōďēƀàśē.", "quickChatIntroSecondary": "Qũĩćķ ćĥàţś śţàŷ ōũţśĩďē ŷōũŕ ţàśķ ƀōàŕď.", + "quickChatTabOrderSaveFailed": "Ćōũĺď ńōţ śàvē Qũĩćķ Ćĥàţ ţàƀ ōŕďēŕ.", + "quickChatTabOrderSaveFailedDescription": "Ŷōũŕ ćũŕŕēńţ ōŕďēŕ ĩś ķēƥţ ĥēŕē. Ŕēĺōàď ţō ŕēśţōŕē ţĥē ĺàśţ śàvēď ōŕďēŕ.", "removeQueuedMessage": "Ŕēḿōvē qũēũēď ḿēśśàĝē", "renameChat": "Ŕēńàḿē ćĥàţ", "reorderQueuedMessage": "Ŕēōŕďēŕ qũēũēď ḿēśśàĝē", diff --git a/apps/web/src/locales/pt-pt/chat.json b/apps/web/src/locales/pt-pt/chat.json index bdfb93fe02..1a80135a31 100644 --- a/apps/web/src/locales/pt-pt/chat.json +++ b/apps/web/src/locales/pt-pt/chat.json @@ -51,6 +51,10 @@ "failedToSendQueuedMessageNow": "Não foi possível enviar agora a mensagem em fila.", "mergeReferenceOverflow": "Demasiadas referências a entidades para unir estas mensagens em fila.", "mergeWithAbove": "Unir com a anterior", + "moveQuickChatTabLeft": "Mover {{name}} para a esquerda", + "moveQuickChatTabRight": "Mover {{name}} para a direita", + "quickChatTabActions": "Ações para {{name}}", + "reorderQuickChatTab": "Reordenar {{name}}", "noTodosYet": "Ainda não há tarefas por fazer.", "newChatTab": "Novo chat", "newQuickChat": "Novo chat rápido", @@ -85,6 +89,8 @@ "quickChatDialogIntro": "Inicie uma conversa rápida com um agente sem criar uma tarefa formal.", "quickChatIntro": "Converse com um agente sobre uma ideia, uma dúvida ou uma base de código.", "quickChatIntroSecondary": "Os chats rápidos ficam fora do seu quadro de tarefas.", + "quickChatTabOrderSaveFailed": "Não foi possível guardar a ordem dos separadores do chat rápido.", + "quickChatTabOrderSaveFailedDescription": "A ordem atual é mantida aqui. Recarregue para restaurar a última ordem guardada.", "removeQueuedMessage": "Remover a mensagem em fila", "renameChat": "Mudar o nome do chat", "renameSavedOnThisDeviceOnly": "Nome guardado apenas neste dispositivo", diff --git a/apps/web/src/locales/zh-cn/chat.json b/apps/web/src/locales/zh-cn/chat.json index debbdbfa48..8a8097f3ca 100644 --- a/apps/web/src/locales/zh-cn/chat.json +++ b/apps/web/src/locales/zh-cn/chat.json @@ -51,6 +51,10 @@ "failedToSendQueuedMessageNow": "立即发送排队消息失败。", "mergeReferenceOverflow": "这些排队中的消息包含过多实体引用,无法合并。", "mergeWithAbove": "与上方消息合并", + "moveQuickChatTabLeft": "将 {{name}} 左移", + "moveQuickChatTabRight": "将 {{name}} 右移", + "quickChatTabActions": "{{name}} 的操作", + "reorderQuickChatTab": "重新排序 {{name}}", "noTodosYet": "暂无待办。", "removeQueuedMessage": "移除排队中的消息", "reorderQueuedMessage": "重新排序队列消息", @@ -92,6 +96,8 @@ "quickChatDialogIntro": "与智能体开始快速对话,无需创建正式任务。", "quickChatIntro": "就想法、问题或代码库与智能体聊天。", "quickChatIntroSecondary": "快速聊天独立于任务看板。", + "quickChatTabOrderSaveFailed": "无法保存快速聊天标签页顺序。", + "quickChatTabOrderSaveFailedDescription": "当前顺序会保留在此处。重新加载可恢复上次保存的顺序。", "renameChat": "重命名聊天", "renameSavedOnThisDeviceOnly": "重命名仅保存在此设备上", "renameSyncFailedDescription": "无法将新名称同步到你的其他设备。", diff --git a/apps/web/src/locales/zh-hk/chat.json b/apps/web/src/locales/zh-hk/chat.json index 77e9367ac7..05edd77b30 100644 --- a/apps/web/src/locales/zh-hk/chat.json +++ b/apps/web/src/locales/zh-hk/chat.json @@ -51,6 +51,10 @@ "failedToSendQueuedMessageNow": "立即傳送排隊訊息失敗。", "mergeReferenceOverflow": "這些排隊中的訊息包含過多實體引用,無法合併。", "mergeWithAbove": "與上方訊息合併", + "moveQuickChatTabLeft": "將 {{name}} 左移", + "moveQuickChatTabRight": "將 {{name}} 右移", + "quickChatTabActions": "{{name}} 的操作", + "reorderQuickChatTab": "重新排序 {{name}}", "noTodosYet": "暫無待辦。", "removeQueuedMessage": "移除排隊中的訊息", "reorderQueuedMessage": "重新排序佇列訊息", @@ -92,6 +96,8 @@ "quickChatDialogIntro": "與代理程式開始快速對話,無需建立正式任務。", "quickChatIntro": "就想法、問題或程式碼庫與代理程式聊天。", "quickChatIntroSecondary": "快速聊天獨立於任務看板。", + "quickChatTabOrderSaveFailed": "無法儲存快速聊天分頁順序。", + "quickChatTabOrderSaveFailedDescription": "目前順序會保留在此處。重新載入可恢復上次儲存的順序。", "renameChat": "重命名聊天", "renameSavedOnThisDeviceOnly": "重命名僅儲存在此設備上", "renameSyncFailedDescription": "無法將新名稱同步到你的其他設備。", diff --git a/apps/web/src/locales/zh-tw/chat.json b/apps/web/src/locales/zh-tw/chat.json index 46f138cbfe..fb51afc34d 100644 --- a/apps/web/src/locales/zh-tw/chat.json +++ b/apps/web/src/locales/zh-tw/chat.json @@ -51,6 +51,10 @@ "failedToSendQueuedMessageNow": "立即傳送排隊訊息失敗。", "mergeReferenceOverflow": "這些排隊中的訊息包含過多實體引用,無法合併。", "mergeWithAbove": "與上方訊息合併", + "moveQuickChatTabLeft": "將 {{name}} 左移", + "moveQuickChatTabRight": "將 {{name}} 右移", + "quickChatTabActions": "{{name}} 的操作", + "reorderQuickChatTab": "重新排序 {{name}}", "noTodosYet": "暫無待辦。", "removeQueuedMessage": "移除排隊中的訊息", "reorderQueuedMessage": "重新排序佇列訊息", @@ -92,6 +96,8 @@ "quickChatDialogIntro": "與代理程式開始快速對話,無需建立正式任務。", "quickChatIntro": "就想法、問題或程式碼庫與代理程式聊天。", "quickChatIntroSecondary": "快速聊天獨立於任務看板。", + "quickChatTabOrderSaveFailed": "無法儲存快速聊天分頁順序。", + "quickChatTabOrderSaveFailedDescription": "目前順序會保留在此處。重新載入可恢復上次儲存的順序。", "renameChat": "重新命名聊天", "renameSavedOnThisDeviceOnly": "重新命名僅儲存在此裝置上", "renameSyncFailedDescription": "無法將新名稱同步到你的其他裝置。", diff --git a/docs/decisions/2026-08-26-quick-chat-agent-titles.md b/docs/decisions/2026-08-26-quick-chat-agent-titles.md new file mode 100644 index 0000000000..826516a90e --- /dev/null +++ b/docs/decisions/2026-08-26-quick-chat-agent-titles.md @@ -0,0 +1,58 @@ +# ADR-2026-08-26-quick-chat-agent-titles: Apply Agent-Generated Titles to Quick Chat + +**Status:** accepted +**Date:** 2026-08-26 +**Area:** backend, frontend, protocol + +## Context + +Ordinary Quick Chat tasks start with labels such as `Agent - Chat 2`. The labels do not describe the +user's request. Users must rename each tab manually to make several chats easy to identify. + +Normal prompt-first tasks use one pending-title owner. The owner receives a title instruction and +the task-bound `set_task_title_kandev` tool. Quick Chat launches its agent before the first user +prompt to discover ACP commands and models. As a result, it does not use the normal first-turn +prompt path. + +## Decision + +If the user's `agent_generated_task_titles` preference is enabled, apply the existing pending-title +lifecycle to ordinary Quick Chat tasks. + +Keep the current Quick Chat label as the provisional title. Add `agent_title_pending: true` at task +creation. The eager launch claims the owner before the agent process starts. The owner receives a +title-capable MCP profile from the start. + +When a user sends a prompt to the running pending owner, add the canonical Kandev context. Structured +agents receive the hidden context. Passthrough agents receive the existing compact title instruction. +Repeat this treatment while the title remains pending. Stop after an agent or user title update +clears the pending state. + +Keep Configuration Chat outside this lifecycle. Its fixed purpose and restricted MCP profile remain +authoritative. Quick Terminal has no task agent and also remains outside this lifecycle. + +Use the existing title mutation, task event, and generated-branch handling. The Quick Chat tab reads +the accepted title from `task.updated` and from later server restoration. + +## Consequences + +- Ordinary Quick Chat tabs gain request-specific names without another title-generation request. +- Eager agent initialization and composer capability discovery remain unchanged. +- The same preference controls agent titles for normal tasks and ordinary Quick Chat tasks. +- If the agent ignores the instruction or a title call fails, the provisional label remains. +- The title instruction can repeat while pending. A successful or manual title update stops it. +- Repository-backed Quick Chats use the existing generated-branch rename and preservation rules. +- Older clients omit the new Quick Chat request field and retain their current behavior. + +## Alternatives Considered + +1. **Delay the agent launch until the first prompt.** Rejected because Quick Chat uses eager ACP + initialization for commands, models, and modes. +2. **Generate a title with a separate utility request.** Rejected because it adds latency, cost, and + another error path. +3. **Keep manual tab rename as the only title path.** Rejected because it repeats work that the task + title capability already performs. +4. **Register the title tool without pending ownership.** Rejected because another session can + overwrite a user title or compete for one task. +5. **Enable the capability for Configuration Chat.** Rejected because its fixed identity and + restricted configuration MCP profile have a different contract. diff --git a/docs/decisions/2026-08-26-quick-chat-tab-order.md b/docs/decisions/2026-08-26-quick-chat-tab-order.md new file mode 100644 index 0000000000..2ebe2b2a34 --- /dev/null +++ b/docs/decisions/2026-08-26-quick-chat-tab-order.md @@ -0,0 +1,70 @@ +# ADR-2026-08-26-quick-chat-tab-order: Store Quick Chat Tab Order as a User Preference + +**Status:** accepted +**Date:** 2026-08-26 +**Area:** backend, frontend, protocol + +## Context + +Quick Chat restores conversation tabs from a service list that uses recent task activity. The live +client keeps its existing order, but a page reload can return the same tabs in a different order. +Quick Terminal descriptors have their own creation sequence. Neither source can represent one user +chosen order across both tab types. + +The tab strip needs a stable baseline and a portable order after a user moves a tab. The preference +must not become a second owner for tab membership or lifecycle. Existing services already own those +contracts. + +## Decision + +Store Quick Chat tab order in backend-owned user settings. Use one ordered reference list for each +workspace: + +```text +quick_chat_tab_order_by_workspace: map +``` + +Use `conversation:` for a conversation and `terminal:` for a Quick Terminal +descriptor. The type prefix keeps the identity domains separate. + +The saved list controls only presentation order. Quick Chat reconciliation continues to own +conversation membership. The Quick Terminal descriptor service continues to own terminal +membership, lifecycle, and stable terminal labels. This keeps the +[server-owned descriptor decision](2026-08-05-server-owned-quick-terminal-descriptors.md) intact. + +Use creation time and task ID as the stable conversation baseline. Keep Quick Terminal `sequence` +as the stable terminal baseline. Without a saved list, show conversations first and terminals +second, in their respective baseline order. + +Resolve a saved list as a partial preference. Keep known references once and in their saved order. +Ignore unknown, duplicate, stale, and cross-workspace references. Append current references that do +not appear in the saved list. Temporary setup tabs stay at the trailing edge and are not persisted. + +The client applies moves optimistically and serializes partial user-settings updates. A completed +move writes the full current list of persisted references for that workspace. The existing boot and +user-settings distribution paths carry the preference to later clients. No new endpoint, database +table, or WebSocket action is required. + +## Consequences + +- A user-chosen mixed chat and terminal order survives reloads, browser restarts, and later clients. +- New tabs appear in a deterministic position until the user moves them. +- Stale order entries cannot recreate or retain a deleted tab. +- Concurrent clients use last accepted user-settings write semantics. They do not coordinate an + active drag in real time. +- A failed save can leave the current client on an optimistic order. A reload returns the last + backend-accepted order. +- Conversation listing stops using recent activity as display order. Activity still updates task + data and does not move a tab. + +## Alternatives Considered + +1. **Store the order in browser storage.** Rejected because the preference must follow the user and + browser storage would compete with backend boot state. +2. **Use conversation activity and terminal sequence.** Rejected because two independent sorts + cannot represent one mixed user order. Activity also moves tabs without user intent. +3. **Put the mixed order in Quick Terminal descriptors or task records.** Rejected because neither + model owns both tab types. It would mix presentation preference with membership and lifecycle. +4. **Add a dedicated relational order table.** Rejected because a bounded per-workspace preference + does not need a new transactional lifecycle. User settings already provide portable partial + updates and client distribution. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 72c0b907b3..e7683ef093 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -209,4 +209,6 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-24-agentctl-local-managed-runtime-cache-repair | [Run cache repair where npm runs](2026-08-24-agentctl-local-managed-runtime-cache-repair.md) | accepted | backend, agentctl, protocol, security | 2026-08-24 | | 2026-08-24-unified-fork-approval-label | [Use One Maintainer Approval Label for Contributor PR Automation](2026-08-24-unified-fork-approval-label.md) | accepted | infra, workflow, security | 2026-08-24 | | 2026-08-25-required-worktree-refresh-fails-closed | [Required Worktree Refresh Fails Closed](2026-08-25-required-worktree-refresh-fails-closed.md) | accepted | backend, security, operations | 2026-08-25 | +| 2026-08-26-quick-chat-tab-order | [Store Quick Chat Tab Order as a User Preference](2026-08-26-quick-chat-tab-order.md) | accepted | backend, frontend, protocol | 2026-08-26 | +| 2026-08-26-quick-chat-agent-titles | [Apply Agent-Generated Titles to Quick Chat](2026-08-26-quick-chat-agent-titles.md) | accepted | backend, frontend, protocol | 2026-08-26 | | 2026-08-27-preserve-legacy-sqlite-before-default-initialization | [Preserve Legacy SQLite Data Before Default Initialization](2026-08-27-preserve-legacy-sqlite-before-default-initialization.md) | accepted | backend, cli, operations | 2026-08-27 | diff --git a/docs/plans/quick-chat-tab-order/plan.md b/docs/plans/quick-chat-tab-order/plan.md new file mode 100644 index 0000000000..3c6215d700 --- /dev/null +++ b/docs/plans/quick-chat-tab-order/plan.md @@ -0,0 +1,123 @@ +--- +created: 2026-08-26 +status: completed +requirements: + - REQ-UI-QUICK-TERMINAL-002 + - REQ-TASKS-QUICK-CHAT-AGENT-TITLES-001 +system_design: + - ../../specs/ui/system-design/quick-terminal.md + - ../../specs/tasks/system-design/quick-chat-agent-titles.md +legacy_specs: [] +--- + +# Implementation Plan: Quick Chat Tab Experience + +## Overview + +This package gives Quick Chat a stable tab order, responsive reorder controls, clear title editing, +and agent-generated conversation titles. The title lifecycle and order persistence form the first +wave because later UI work uses their stable state. + +The implementation then adds sortable interactions and edit polish. Desktop and mobile Playwright +coverage proves the combined user outcome in the final wave. + +## Scope + +### In scope + +- Persist one mixed conversation and terminal tab order for each user and workspace. +- Support mouse, touch, keyboard, and coarse-pointer reorder actions. +- Improve spinner spacing and the conversation-title editor. +- Give an ordinary Quick Chat owner the normal title instruction and `set_task_title_kandev` tool. +- Show the accepted agent title in the current tab and after reload. +- Update the public Quick Chat and MCP guidance for the title behavior. + +### Out of scope + +- Quick Terminal title editing. +- Agent-generated titles for Configuration Chat. +- Live drag synchronization between clients. +- Changes to the Quick Chat dialog geometry or content scroll owner. +- Split panes and command-palette tab actions. + +## Technical approach + +### Order persistence + +Add `quick_chat_tab_order_by_workspace` to backend user settings. Use namespaced conversation and +terminal references. Resolve saved references against authoritative membership and stable baseline +sorts. Apply moves optimistically through a serialized settings save queue. + +### Agent-generated titles + +Send the current `agent_generated_task_titles` preference in the ordinary Quick Chat create request. +Mark an enabled task as title-pending while keeping its current label as the provisional title. + +Reuse the existing owner claim before eager launch. The executor then starts the owner with the +title-capable MCP profile. When the pending owner receives a prompt, add canonical Kandev context. +Keep the existing task update and Quick Chat synchronization paths. + +### Tab interactions + +Build one resolved descriptor list before rendering. Use dnd-kit mouse, touch, and keyboard sensors. +Add visible coarse-pointer actions for rename, directional moves, and close. + +### Editing polish + +Add a fixed spinner-to-title gap. Replace the tab close action with Save and Cancel during rename. +Use one commit path and one restore path. + +## Tests + +- `quick_chat_sessions_test.go` covers the stable conversation baseline. +- User settings and frontend state tests cover `AC-UI-QUICK-TERMINAL-002.1`, `.4`, and `.5`. +- Quick Chat component tests cover `AC-UI-QUICK-TERMINAL-002.2`, `.3`, `.6`, `.7`, and `.8`. +- Quick Chat handler tests cover `AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.1` and `.4`. +- Message, orchestrator, executor, and MCP tests cover title criteria `.2`, `.5`, `.6`, and `.7`. +- Quick Chat task-event tests cover title criterion `.3`. +- Public-doc validation covers the Quick Chat guide and the MCP title reference. + +## E2E tests + +- `apps/web/e2e/tests/chat/quick-chat.spec.ts` proves mixed-tab reorder, reload persistence, clear + rename controls, and the accepted agent title. It covers both requirement sets. +- `apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts` runs in `mobile-chrome`. It proves touch or + menu reorder, rename discovery, target size, overflow containment, and reload persistence. + +## Work orders + +Wave 1 establishes independent state contracts: + +- [x] [Task 01: Persist tab order](task-01-persist-tab-order.md) +- [x] [Task 05: Enable Quick Chat agent titles](task-05-enable-agent-titles.md) + +Wave 2 changes the shared tab item in sequence: + +- [x] [Task 02: Add sortable tab interactions](task-02-add-sortable-tab-strip.md) +- [x] [Task 03: Improve tab editing](task-03-improve-tab-editing.md) + +Wave 3 proves the integrated result: + +- [x] [Task 04: Prove desktop and mobile behavior](task-04-prove-tab-order-e2e.md) + +Tasks 01 and 05 are parallel-safe. Task 02 depends on Task 01. Task 03 depends on Task 02. Task 04 +depends on all implementation work orders. + +## Verification results + +Passed backend unit tests for settings, tasks, handlers, orchestration, MCP, and the mock agent. +Passed the web typecheck, lint, i18n checks and ratchet, focused Quick Chat tests (28 files, 235 +tests), public-doc validation, and specification lint. The complete desktop Quick Chat suite passed +17 tests. The mobile Quick Chat suite passed its mixed-tab, rename, target-size, and overflow test. + +## Risks + +- A saved order can contain deleted or duplicate references. The resolver must ignore them without + changing membership. +- Touch drag can compete with horizontal scrolling. Delayed activation and visible move actions + provide both outcomes. +- The Quick Chat agent starts before its first prompt. The title-capable profile and prompt context + must agree before Kandev dispatches that prompt. +- The title instruction repeats while title state remains pending. A successful agent or user title + update must stop later injection. +- New action labels affect every required locale catalog. diff --git a/docs/plans/quick-chat-tab-order/task-01-persist-tab-order.md b/docs/plans/quick-chat-tab-order/task-01-persist-tab-order.md new file mode 100644 index 0000000000..03a6bcf518 --- /dev/null +++ b/docs/plans/quick-chat-tab-order/task-01-persist-tab-order.md @@ -0,0 +1,86 @@ +--- +id: "01-persist-tab-order" +title: "Persist tab order" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-QUICK-TERMINAL-002 +acceptance_criteria: + - AC-UI-QUICK-TERMINAL-002.1 + - AC-UI-QUICK-TERMINAL-002.4 + - AC-UI-QUICK-TERMINAL-002.5 +system_design: + - ../../specs/ui/system-design/quick-terminal.md +--- + +# Task 01: Persist Tab Order + +## Summary + +Store one portable mixed-tab order for each workspace. Resolve it against current conversation and +terminal membership without changing lifecycle ownership. + +## In scope + +- Add the user-settings field and all boot, PATCH, event, and hydration projections. +- Replace activity-based conversation order with a stable creation baseline. +- Add the order resolver, optimistic state, serialized saves, and error state. + +## Out of scope + +- Drag sensors and tab action menus. +- Rename presentation. +- Agent-generated titles. + +## Acceptance + +- Backend and frontend settings round-trip a workspace order without losing sibling settings. +- The resolver ignores invalid references and appends new persisted tabs in stable order. +- A failed save preserves membership and the active tab while it shows a sync error. + +## Verification + +```bash +(cd apps/backend && go test ./internal/user/... ./internal/task/service ./internal/backendapp -count=1) +(cd apps/web && pnpm vitest run lib/ssr/user-settings.test.ts lib/state/hydration/hydrator.test.ts lib/state/slices/ui) +(cd apps/web && pnpm run typecheck) +``` + +## Files likely touched + +- `apps/backend/internal/user/models/models.go` +- `apps/backend/internal/user/dto/dto.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/backend/internal/task/service/quick_chat_sessions.go` +- `apps/web/lib/types/http-user-settings.ts` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/lib/state/default-state.ts` +- `apps/web/lib/state/slices/ui/` + +## Dependencies + +None. + +## Risks + +- Settings events and responses can arrive out of order. The existing settings revision remains + authoritative, and local writes must be serialized. + +## Parallelism + +`parallel-safe` with Task 05. + +## Inputs + +- UI system design sections for tab identity, order resolution, persistence, and errors. +- The sidebar task-preference save queue and the existing Quick Chat reconciliation tests. + +## Results + +Implemented the stable creation-order baseline, per-workspace user-settings field, boot and +hydration mapping, unknown-reference resolver, optimistic order state, and serialized save queue. +The backend and frontend focused checks pass. diff --git a/docs/plans/quick-chat-tab-order/task-02-add-sortable-tab-strip.md b/docs/plans/quick-chat-tab-order/task-02-add-sortable-tab-strip.md new file mode 100644 index 0000000000..958eef10a3 --- /dev/null +++ b/docs/plans/quick-chat-tab-order/task-02-add-sortable-tab-strip.md @@ -0,0 +1,83 @@ +--- +id: "02-add-sortable-tab-strip" +title: "Add sortable tab interactions" +status: completed +wave: 2 +depends_on: + - "01-persist-tab-order" +plan: "plan.md" +requirements: + - REQ-UI-QUICK-TERMINAL-002 +acceptance_criteria: + - AC-UI-QUICK-TERMINAL-002.2 + - AC-UI-QUICK-TERMINAL-002.3 + - AC-UI-QUICK-TERMINAL-002.8 +system_design: + - ../../specs/ui/system-design/quick-terminal.md +--- + +# Task 02: Add Sortable Tab Interactions + +## Summary + +Render one sortable list for persisted conversations and terminals. Provide equivalent mouse, +touch, keyboard, and coarse-pointer move paths. + +## In scope + +- Add the dnd-kit list and mouse, touch, and keyboard sensors. +- Add visible coarse-pointer tab actions with directional moves and close. +- Contain horizontal overflow inside the tab strip. + +## Out of scope + +- Title persistence and order resolution internals. +- Rename input styling and Save or Cancel behavior. +- Terminal title editing. + +## Acceptance + +- Every input mode changes the same ordered state and preserves the active tab. +- Touch scrolling and ordinary tab selection remain usable before drag activation. +- Coarse-pointer actions meet the 44 CSS pixel target and cause no document overflow. + +## Verification + +```bash +(cd apps/web && pnpm vitest run components/quick-chat lib/state/slices/ui) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps && pnpm --filter @kandev/web lint) +``` + +## Files likely touched + +- `apps/web/components/quick-chat/quick-chat-modal.tsx` +- A sortable strip component under `apps/web/components/quick-chat/` +- `apps/web/components/quick-chat/quick-chat-tab-item.tsx` +- `apps/web/components/quick-chat/quick-terminal-tab-item.tsx` +- Quick Chat component tests and required locale catalogs + +## Dependencies + +Task 01. + +## Risks + +- Pointer event boundaries can turn a click or close action into a drag. +- The tab item is also changed by Task 03, so the work orders run in sequence. + +## Parallelism + +`sequential` + +## Inputs + +- UI system design sections for interaction, accessibility, and mobile behavior. +- `sidebar-view-chips.tsx` as the existing horizontal sensor pattern. + +## Results + +Implemented one mixed sortable strip for conversation and terminal tabs with mouse, delayed touch, +and keyboard sensors. Added mobile directional controls, 44 CSS pixel coarse-pointer targets, and +contained horizontal overflow. The focused component, state, typecheck, i18n, and lint checks pass. diff --git a/docs/plans/quick-chat-tab-order/task-03-improve-tab-editing.md b/docs/plans/quick-chat-tab-order/task-03-improve-tab-editing.md new file mode 100644 index 0000000000..e27b2e6947 --- /dev/null +++ b/docs/plans/quick-chat-tab-order/task-03-improve-tab-editing.md @@ -0,0 +1,80 @@ +--- +id: "03-improve-tab-editing" +title: "Improve tab editing" +status: completed +wave: 2 +depends_on: + - "02-add-sortable-tab-strip" +plan: "plan.md" +requirements: + - REQ-UI-QUICK-TERMINAL-002 +acceptance_criteria: + - AC-UI-QUICK-TERMINAL-002.6 + - AC-UI-QUICK-TERMINAL-002.7 +system_design: + - ../../specs/ui/system-design/quick-terminal.md +--- + +# Task 03: Improve Tab Editing + +## Summary + +Make the working indicator and rename state visually distinct. Use explicit edit actions that cannot +accidentally close the tab. + +## In scope + +- Add at least 6 CSS pixels between the grid spinner and title. +- Add the rename input treatment, Save, Cancel, and accessible labels. +- Unify commit, restore, keyboard, blur, and error behavior. + +## Out of scope + +- Drag sensors and order persistence. +- Terminal title editing. +- Changes to the backing task rename API. + +## Acceptance + +- Rename mode selects the title and shows a clear input plus Save and Cancel. +- Enter or Save commits once. Escape or Cancel restores once. Blur cannot submit twice. +- Coarse-pointer edit targets meet the mobile size and text requirements. + +## Verification + +```bash +(cd apps/web && pnpm vitest run components/quick-chat) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps && pnpm --filter @kandev/web lint) +``` + +## Files likely touched + +- `apps/web/components/quick-chat/quick-chat-tab-item.tsx` +- Quick Chat tab-item tests +- Required locale catalogs + +## Dependencies + +Task 02. + +## Risks + +- Blur, Save, and Enter can race and submit duplicate renames. One guarded commit path must own the + request. + +## Parallelism + +`sequential` + +## Inputs + +- UI system design sections for rename behavior and spinner spacing. +- The existing task-backed Quick Chat rename helper. + +## Results + +Implemented the fixed spinner gap and responsive rename editor. Save, Cancel, Enter, Escape, blur, +and IME paths share guarded commit and restore behavior. Coarse-pointer edit controls meet the +mobile target size. The focused Quick Chat tests, typecheck, i18n checks, and lint pass. diff --git a/docs/plans/quick-chat-tab-order/task-04-prove-tab-order-e2e.md b/docs/plans/quick-chat-tab-order/task-04-prove-tab-order-e2e.md new file mode 100644 index 0000000000..132f71e5b9 --- /dev/null +++ b/docs/plans/quick-chat-tab-order/task-04-prove-tab-order-e2e.md @@ -0,0 +1,95 @@ +--- +id: "04-prove-tab-order-e2e" +title: "Prove desktop and mobile behavior" +status: completed +wave: 3 +depends_on: + - "01-persist-tab-order" + - "02-add-sortable-tab-strip" + - "03-improve-tab-editing" + - "05-enable-agent-titles" +plan: "plan.md" +requirements: + - REQ-UI-QUICK-TERMINAL-002 + - REQ-TASKS-QUICK-CHAT-AGENT-TITLES-001 +acceptance_criteria: + - AC-UI-QUICK-TERMINAL-002.1 + - AC-UI-QUICK-TERMINAL-002.2 + - AC-UI-QUICK-TERMINAL-002.3 + - AC-UI-QUICK-TERMINAL-002.6 + - AC-UI-QUICK-TERMINAL-002.7 + - AC-UI-QUICK-TERMINAL-002.8 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.2 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.3 +system_design: + - ../../specs/ui/system-design/quick-terminal.md + - ../../specs/tasks/system-design/quick-chat-agent-titles.md +--- + +# Task 04: Prove Desktop and Mobile Behavior + +## Summary + +Add production-build browser proof for the complete tab experience. Use a deterministic mock-agent +command to prove the real title MCP path and visible tab update. + +## In scope + +- Replace the desktop test that expects activity-based reorder after reload. +- Prove mixed-tab drag, rename controls, agent title update, and reload persistence. +- Prove the mobile move path, rename discovery, target size, and overflow containment. + +## Out of scope + +- Broad Playwright verification outside the Quick Chat files. +- Container and SSH projects. +- Visual snapshot baselines. + +## Acceptance + +- Desktop preserves mixed order and the generated title after reload. +- Mobile proves an equivalent move outcome with no document overflow and legal touch targets. +- Both focused projects pass with retries disabled and discover the intended tests. + +## Verification + +```bash +(cd apps/backend && go test ./cmd/mock-agent -count=1) +make -C apps/backend build +(cd apps/web && pnpm e2e:run tests/chat/quick-chat.spec.ts -- --retries=0) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-tabs.spec.ts -- --retries=0) +``` + +## Files likely touched + +- `apps/backend/cmd/mock-agent/handler.go` +- `apps/backend/cmd/mock-agent/main.go` +- `apps/backend/cmd/mock-agent/mock_agent_test.go` +- `apps/web/e2e/tests/chat/quick-chat.spec.ts` +- `apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts` +- A shared Quick Chat E2E helper or page object + +## Dependencies + +Tasks 01, 02, 03, and 05. + +## Risks + +- The managed E2E runner uses the prebuilt mock-agent binary. The backend build must occur after a + fixture change. +- Desktop and mobile require separate project commands. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs and their E2E mappings. +- The mock-agent `/subtask` MCP command as the nearest real-tool fixture pattern. + +## Results + +The deterministic mock-agent path now proves the real title MCP call and task event relabeling. The +complete desktop Quick Chat spec passed 17 tests with retries disabled. The mobile-chrome spec +passed its mixed-tab move, rename, target-size, and overflow scenario with retries disabled. diff --git a/docs/plans/quick-chat-tab-order/task-05-enable-agent-titles.md b/docs/plans/quick-chat-tab-order/task-05-enable-agent-titles.md new file mode 100644 index 0000000000..9d113b2a9c --- /dev/null +++ b/docs/plans/quick-chat-tab-order/task-05-enable-agent-titles.md @@ -0,0 +1,99 @@ +--- +id: "05-enable-agent-titles" +title: "Enable Quick Chat agent titles" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-QUICK-CHAT-AGENT-TITLES-001 +acceptance_criteria: + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.1 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.2 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.3 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.4 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.5 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.6 + - AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.7 +system_design: + - ../../specs/tasks/system-design/quick-chat-agent-titles.md +--- + +# Task 05: Enable Quick Chat Agent Titles + +## Summary + +Extend the existing single-owner title lifecycle to ordinary Quick Chat tasks. Preserve eager agent +startup while the first pending-owner prompt receives the normal title context. + +## In scope + +- Send the user preference through the Quick Chat create request. +- Persist pending intent and claim the eager Quick Chat owner before process launch. +- Inject structured or passthrough title context while the owner remains pending. +- Reuse the existing title mutation, manual-rename precedence, and Quick Chat task-event update. +- Update the public Quick Chat and MCP title documentation. + +## Out of scope + +- Configuration Chat and Quick Terminal. +- A second title-generation agent or endpoint. +- Changes to title validation, ownership reassignment, or branch rules. + +## Acceptance + +- Enabled Quick Chats start with a provisional title and expose the owner-only tool before the first + user request. +- Pending-owner prompts contain the correct server context for structured and passthrough agents. +- Disabled, manually renamed, failed, config, and non-owner paths preserve existing guarantees. The + public guide documents the enabled behavior and exclusions. + +## Verification + +```bash +(cd apps/backend && go test ./internal/task/handlers ./internal/orchestrator ./internal/orchestrator/executor ./internal/mcp/server ./internal/sysprompt -count=1) +(cd apps/web && pnpm vitest run components/quick-chat/use-quick-chat-modal.test.ts lib/ws/handlers/quick-chat.test.ts) +(cd apps/web && pnpm run typecheck) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `apps/web/components/quick-chat/use-quick-chat-modal.ts` +- `apps/web/components/quick-chat/use-quick-chat-modal.test.ts` +- `apps/web/lib/api/domains/workspace-api.ts` +- `apps/backend/internal/task/handlers/task_http_handlers.go` +- `apps/backend/internal/task/handlers/task_http_handlers_test.go` +- `apps/backend/internal/task/handlers/message_handlers.go` +- A focused Quick Chat title-context handler test +- Orchestrator and executor tests for the existing eager owner/profile seam +- `docs/public/developer-tools.md` +- `docs/public/automation-and-mcp.md` + +## Dependencies + +None. + +## Risks + +- Eager launch and first-prompt composition occur in different paths. Tests must prove that both use + the same persisted owner. +- The context repeats until pending state clears. Manual and agent title updates must stop it. + +## Parallelism + +`parallel-safe` with Task 01. + +## Inputs + +- The task-system title design and three related ADRs. +- `startQuickChatForAgent`, `httpStartQuickChat`, `ClaimTaskTitleSession`, and the MCP profile resolver. + +## Results + +Enabled ordinary Quick Chat agent-generated titles behind the existing user setting. Eager sessions +carry a durable provisional title and one claimed owner; structured and passthrough first prompts +receive the owner-only title capability. Config, Office, disabled, manual-rename, and non-owner +paths retain their existing restrictions. The focused backend/frontend tests, public-doc validation, +and full Quick Chat E2E coverage pass. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 76bb4188ba..dfc2f15ff5 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -494,14 +494,18 @@ A task session currently registers these tool groups: When **Settings → General → Task Actions → Agent-generated task titles** is enabled (the default; an explicitly saved **off** value remains off), a task-mode session for a newly created task or subtask can expose `set_task_title_kandev`. The first eligible session to launch atomically claims the handoff and is -prompted to call it before any other work, even though the task already has a provisional title from the -prompt. Use a short title phrase targeting about six words in sentence case rather than a sentence or -progress update. +prompted to call it before any other work, even though the task already has a provisional title. Use a +short title phrase targeting about six words in sentence case rather than a sentence or progress update. The tool is omitted for ordinary tasks, tasks created while the setting was disabled, config sessions, Office sessions, and every later session on the task, even if the owner fails before renaming it. A human rename wins if it happens first; a late owner call returns `title_not_pending`, while a non-owner call returns `title_not_owner`, without changing the title. +The same setting applies to ordinary Quick Chat. Quick Chat keeps its agent-and-chat-number label until +the owner receives the first user request, then the owner can set a more useful title. The owner keeps +the title capability while the title is pending, so a later request can retry after an ignored or failed +call. Configuration Chat and Quick Terminal do not expose this capability. + When the owner accepts a generated title, Kandev also updates the names of the task's Kandev-managed branches from that final title and refreshes the session's branch snapshots. This is evaluated per repository: a repository opened from an existing checkout branch (including a GitHub PR) is preserved, diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index c4d42212af..501ecf7a03 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -47,6 +47,13 @@ Quick Chat supports multiple tabs, tab renaming, and **+** to open another ordin Your chats and their names are shared by every browser and device signed in to the same Kandev instance. Starting, renaming, or closing a chat on one device updates the others, and a device that was offline catches up when it reconnects. +When **Settings > General > Task Actions > Agent-generated task titles** is enabled, an ordinary +Quick Chat starts with its normal provisional label and its owner agent can replace that label with a +short title based on your first request. Structured and CLI-passthrough chats receive the title +instruction through their existing first-turn path. The new title appears on every connected device +and survives reload. Configuration Chat and Quick Terminal are excluded. If you disable the setting, +or rename the chat first, the provisional or user-selected title remains authoritative. + Closing a real chat tab permanently deletes its conversation, hidden backing task data, and associated worktree. There is no undo. Kandev also deletes abandoned chats after seven days; cleanup runs when the backend starts and then once per day. Only chats whose session is `RUNNING` or `IDLE` are protected from age-based cleanup. Old `CREATED`, `STARTING`, or `WAITING_FOR_INPUT` chats can expire, so do not use Quick Chat for durable work. If **Start chat** is disabled, select a profile and finish every repository/branch row. If a repository is missing, confirm that it belongs to the current workspace and refresh the repository configuration. Use a normal task when the result must remain visible on a board or become a reviewed PR. diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index fc877af55f..68f3072fc6 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -64,6 +64,7 @@ signals, and task-scoped scheduling contracts. - [Prevent Agent Auto-Start On Open](requirements/prevent-agent-autostart-on-open.md) - [Prompt attachments](requirements/prompt-attachments.md) - [Quick Chat Sessions, Persistence, and Expiration](requirements/quick-chat-expiration.md) +- [Quick Chat Agent Titles](requirements/quick-chat-agent-titles.md) - [Quick Chat Repository Context](requirements/quick-chat-repository-context.md) - [Remote Contribution Tasks](requirements/remote-contribution-tasks.md) - [Rich task title previews](requirements/rich-task-title-previews.md) @@ -113,6 +114,7 @@ signals, and task-scoped scheduling contracts. - [Attach Workspace Sources](system-design/attach-workspace-sources.md) +- [Quick Chat Agent Titles](system-design/quick-chat-agent-titles.md) - [Active clarification lifecycle](system-design/clarification-active-lifecycle.md) - [External task ID idempotency operations](system-design/external-id-idempotency-operations.md) - [External task ID idempotency](system-design/external-id-idempotency.md) diff --git a/docs/specs/tasks/requirements/agent-generated-titles.md b/docs/specs/tasks/requirements/agent-generated-titles.md index 274f6cb633..b4e5d8e18a 100644 --- a/docs/specs/tasks/requirements/agent-generated-titles.md +++ b/docs/specs/tasks/requirements/agent-generated-titles.md @@ -294,4 +294,4 @@ provisional-title name. ## Implementation plan - [Agent-Generated Task Titles](../../../plans/agent-generated-task-titles/plan.md) -- [Agent Title Branch Renaming](../../../plans/agent-title-branch-renaming/plan.md) \ No newline at end of file +- [Agent Title Branch Renaming](../../../plans/agent-title-branch-renaming/plan.md) diff --git a/docs/specs/tasks/requirements/quick-chat-agent-titles.md b/docs/specs/tasks/requirements/quick-chat-agent-titles.md new file mode 100644 index 0000000000..deeb7384a9 --- /dev/null +++ b/docs/specs/tasks/requirements/quick-chat-agent-titles.md @@ -0,0 +1,47 @@ +--- +status: active +system: tasks +created: 2026-08-26 +owners: + - kandev +--- + +# Quick Chat Agent Titles Requirements + +## Overview + +An ordinary Quick Chat starts before the user sends a request. Its provisional label identifies the +agent, but it does not describe the conversation. The existing task-title agent can give each chat a +useful name without another generation request. + +The task system owns this behavior because each Quick Chat tab uses a task title. The UI shows task +updates but does not own title state or MCP authority. + +## Requirements + +### REQ-TASKS-QUICK-CHAT-AGENT-TITLES-001: Quick Chat Agent Titles + +**Intent:** An ordinary Quick Chat agent gives its tab a concise title that describes the user's first request. + +**User story:** As a Quick Chat user, I want the agent to name the chat, so that I can find the conversation again. + +#### Acceptance criteria + +- **AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.1:** When agent-generated task titles are enabled, a new ordinary Quick Chat starts with a usable provisional tab title. +- **AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.2:** Before the owner agent processes the first user request, its Kandev context instructs it to set a concise task title first. Its MCP catalog contains `set_task_title_kandev`. +- **AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.3:** When the owner agent sets a valid title, the existing Quick Chat tab shows that title without a reload or a new session. +- **AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.4:** When the user disables agent-generated task titles, a new Quick Chat keeps its provisional title. Its agent receives no title instruction or title tool. +- **AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.5:** When the user renames a pending Quick Chat first, the user title remains authoritative. A later agent title call cannot replace it. +- **AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.6:** When the owner agent ignores the instruction or a title call fails, the provisional title remains usable after reload. Another session does not inherit title ownership. +- **AC-TASKS-QUICK-CHAT-AGENT-TITLES-001.7:** Structured and passthrough Quick Chat agents receive the title instruction through their existing first-turn context treatment. Configuration Chat and Quick Terminal do not receive this capability. + +## Out of scope + +- Agent-generated titles for Configuration Chat or Quick Terminal. +- A separate title-generation request or agent. +- Changes to normal task-title validation, ownership reassignment, or branch rules. +- Removal of manual Quick Chat rename controls. + +## System design + +[Quick Chat Agent Titles](../system-design/quick-chat-agent-titles.md) diff --git a/docs/specs/tasks/system-design/quick-chat-agent-titles.md b/docs/specs/tasks/system-design/quick-chat-agent-titles.md new file mode 100644 index 0000000000..edd425d512 --- /dev/null +++ b/docs/specs/tasks/system-design/quick-chat-agent-titles.md @@ -0,0 +1,123 @@ +--- +status: draft +system: tasks +requirements: + - REQ-TASKS-QUICK-CHAT-AGENT-TITLES-001 +created: 2026-08-26 +owners: + - kandev +--- + +# Quick Chat Agent Titles System Design + +## Purpose and boundaries + +The task system owns the title, pending state, session owner, MCP mutation, and task update event. +The Quick Chat UI supplies the provisional label and shows the resulting task update. + +This design extends the existing generated-title lifecycle to ordinary Quick Chat tasks. It does +not change Configuration Chat, Quick Terminal, or manual tab rename behavior. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-TASKS-QUICK-CHAT-AGENT-TITLES-001` | [Control flow](#control-flow) | + +## Components and responsibilities + +- `use-quick-chat-modal.ts` reads `agentGeneratedTaskTitles`. When it creates an ordinary Quick Chat, + it sends the choice. +- `httpStartQuickChat` keeps the current provisional label. It marks the task as title-pending when + the request enables agent titles. +- The orchestrator claims the first eligible Quick Chat session through the existing title-owner + compare-and-set before it starts the eager agent process. +- The executor derives the MCP profile from the pending task and owner session. This profile + registers `set_task_title_kandev` before the agent accepts a user request. +- The task message handler adds the canonical Kandev context to a pending owner's prompt. If eager + initialization moved the session to `WAITING_FOR_INPUT`, the same rule applies. +- The existing title mutation publishes `task.updated`. The Quick Chat task-event handler maps the + accepted task title to the current tab. + +## Data and contracts + +The Quick Chat create request adds one optional field: + +```json +{ + "auto_title": true +} +``` + +The frontend sends the current `agent_generated_task_titles` preference. An omitted or false field +keeps the current Quick Chat behavior. + +When `auto_title` is true, the handler keeps the selected agent label and chat number as the +provisional title. It also adds `agent_title_pending: true` to task metadata. It does not call the +normal prompt-derived title helper because a Quick Chat has no user prompt at creation time. + +The existing `agent_title_owner_session_id` field identifies the one owner. The existing +`set_task_title_kandev` schema, authorization, validation, branch handling, and response remain +unchanged. + +## Control flow + +1. The user selects an agent. The client creates an ordinary Quick Chat with a provisional title + and the current agent-title preference. +2. The backend persists the task. If the preference is enabled, the task is title-pending. +3. Quick Chat keeps its eager launch. The launch claims title ownership before the process starts. +4. The executor starts the agent with the title-capable task MCP profile. Eager ACP capability + discovery remains available to the composer. +5. When the user sends a prompt to the pending owner, the message handler adds the Kandev context + before message persistence and agent dispatch. +6. A structured agent receives the normal hidden context and the title instruction. A passthrough + agent receives the existing compact instruction before the prompt in its native terminal. +7. If the title remains pending, a later prompt includes the instruction again. This gives a failed + or ignored call a retry path without assigning another owner. +8. A successful title call clears pending ownership and publishes `task.updated`. The active and + restored Quick Chat tab names then use the accepted task title. + +## Failure and recovery + +If task creation or eager launch fails, the existing Quick Chat rollback removes the ephemeral task. +If title ownership cannot be persisted, the launch fails before it exposes the title tool. + +If prompt context composition fails, Kandev does not send a prompt with an ambiguous title +capability. The ordinary message error path reports the error. + +If the tool is unavailable, ignored, or rejected, the provisional title remains. The pending owner +keeps its capability after resume. A different session cannot become the owner. + +A manual tab rename uses the existing task-title update. That update clears pending ownership. The +running MCP server can retain its registered schema, but a later call returns `title_not_pending`. + +## Persistence and compatibility + +This change uses existing task and session metadata. It adds no database column or migration. Older +clients omit `auto_title` and keep their current behavior. + +The provisional and accepted titles survive reload through the existing task row. The pending state +and owner also retain their current restart guarantees. + +## Permissions and security + +The MCP server injects its task and session identifiers. The agent cannot select another task. The +existing owner compare-and-set and task authorization remain the mutation boundary. + +The server creates all Kandev context blocks. The message handler canonicalizes user content before +it stores or sends the prompt. User text cannot forge the title instruction or tool authority. + +## Test mapping + +- Handler tests cover `auto_title`, provisional titles, disabled behavior, and config exclusion. +- Orchestrator and executor tests cover eager owner claim, title-capable MCP mode, and resume. +- Message-handler tests cover structured context, passthrough context, retry, and canonical storage. +- Frontend tests cover preference projection and the unchanged provisional tab state. +- Playwright uses a deterministic mock-agent title command. It proves that the tab title changes and + remains after reload. + +## Related decisions + +- [Bind Agent Title Generation to Pending Tasks](../../../decisions/2026-07-31-agent-generated-task-titles.md) +- [Assign Agent Task Titles to One Session](../../../decisions/2026-08-02-single-owner-agent-task-titles.md) +- [Apply Agent-Generated Titles to Quick Chat](../../../decisions/2026-08-26-quick-chat-agent-titles.md) diff --git a/docs/specs/ui/requirements/quick-terminal.md b/docs/specs/ui/requirements/quick-terminal.md index d53c67d10b..1c3e50c5de 100644 --- a/docs/specs/ui/requirements/quick-terminal.md +++ b/docs/specs/ui/requirements/quick-terminal.md @@ -25,8 +25,8 @@ Quick Chat and Quick Terminal are both short-lived utilities reached from the sa - **AC-UI-QUICK-TERMINAL-001.3:** The existing Quick Terminal launchers use a reuse-or-create policy scoped to the active workspace: they open the most recently activated terminal tab when one exists, and create the first terminal tab otherwise. - **AC-UI-QUICK-TERMINAL-001.4:** The tab-strip plus button opens a creation menu grouped like the task-detail Dockview add menu: an **Agents** section with **New Agent**, a separator, and a **Terminals** section with **New Terminal**. Existing tabs remain directly selectable in the tab strip rather than being duplicated in the creation menu. Because the plus button sits at the leading edge of the tab strip, its menu opens toward the trailing edge (aligned to the button's start) so it does not overhang the workspace edge. - **AC-UI-QUICK-TERMINAL-001.5:** Choosing **New Agent** preserves the current ordinary/configuration setup flow. Choosing **New Terminal** always creates and activates a distinct host-shell terminal, even when another terminal exists. -- **AC-UI-QUICK-TERMINAL-001.6:** Chat and terminal tabs share one horizontal tab strip. Conversation ordering and configuration indicators remain unchanged; terminal tabs are ordered by creation and use a terminal icon with workspace-local labels such as `Terminal 1`, `Terminal 2`. -- **AC-UI-QUICK-TERMINAL-001.7:** Renameable conversation tabs expose **Rename** from a context menu on desktop right-click and the equivalent touch long-press gesture. The existing inline editor and backing-task rename persistence remain unchanged; terminal labels stay fixed. +- **AC-UI-QUICK-TERMINAL-001.6:** Chat and terminal tabs share one horizontal tab strip. Configuration indicators and workspace-local terminal labels such as `Terminal 1` and `Terminal 2` remain unchanged. +- **AC-UI-QUICK-TERMINAL-001.7:** Renameable conversation tabs expose **Rename** from a context menu on fine-pointer devices and from a visible tab action on coarse-pointer devices. The backing-task rename persistence remains unchanged. Terminal labels stay fixed. - **AC-UI-QUICK-TERMINAL-001.8:** Multiple terminal tabs can run concurrently. Input, output, resize, exit, and error state belong to the selected terminal and must not affect sibling terminals. - **AC-UI-QUICK-TERMINAL-001.9:** When Escape closes the shared dialog, the system shall return focus to the launcher without a visible focus indicator. When focus later returns through ordinary keyboard navigation, the launcher shall show its normal focus indicator. @@ -35,6 +35,23 @@ Quick Chat and Quick Terminal are both short-lived utilities reached from the sa - Changing pointer dismissal, tooltip behavior, or focus indicators on unrelated controls. - Changing the Quick Chat layout or its mobile touch controls. +### REQ-UI-QUICK-TERMINAL-002: Quick Chat Tab Organization + +**Intent:** Users can keep a deliberate Quick Chat tab layout and can recognize when a tab title is ready for editing. + +**User story:** As a Kandev user, I want to arrange and rename Quick Chat tabs, so that the dialog remains predictable after I return to it. + +#### Acceptance criteria + +- **AC-UI-QUICK-TERMINAL-002.1:** A workspace keeps one stable order for persisted conversation and terminal tabs. The order survives dialog close, page reload, browser restart, and a later load by the same user on another client. +- **AC-UI-QUICK-TERMINAL-002.2:** Fine-pointer users can drag persisted tabs into any position. Touch users can press and hold a tab to drag it without blocking ordinary taps or horizontal tab-strip scrolling. +- **AC-UI-QUICK-TERMINAL-002.3:** Keyboard users and coarse-pointer users can move a tab without a precision drag. Coarse-pointer tab actions have a target size of at least 44 by 44 CSS pixels. +- **AC-UI-QUICK-TERMINAL-002.4:** The system ignores unknown, duplicate, and stale saved tab references. It appends new persisted tabs in a stable baseline order. Temporary setup tabs remain at the trailing edge and do not enter the saved order. +- **AC-UI-QUICK-TERMINAL-002.5:** A failed order save leaves every tab available and keeps the active tab selected. The dialog shows a user-visible save error. A later load can restore the last order that the backend accepted. +- **AC-UI-QUICK-TERMINAL-002.6:** A working-state grid spinner has at least 6 CSS pixels of horizontal space before its tab title. +- **AC-UI-QUICK-TERMINAL-002.7:** Rename mode visibly distinguishes the tab with an edit-state background and border, shows a focused bordered input with selected text, and omits inline **Save** and **Cancel** actions. Enter commits a trimmed name, Escape restores the previous name, and blur commits without a duplicate rename. +- **AC-UI-QUICK-TERMINAL-002.8:** On phone and tablet viewports, the tab strip contains its own horizontal overflow. The selected content remains the dialog scroll owner, and the feature does not cause document horizontal overflow. + ## System design The migrated technical source is split into [part 1](../system-design/quick-terminal.md). diff --git a/docs/specs/ui/system-design/quick-terminal.md b/docs/specs/ui/system-design/quick-terminal.md index 0179c02381..495be6ecab 100644 --- a/docs/specs/ui/system-design/quick-terminal.md +++ b/docs/specs/ui/system-design/quick-terminal.md @@ -3,6 +3,7 @@ status: draft system: ui requirements: - REQ-UI-QUICK-TERMINAL-001 + - REQ-UI-QUICK-TERMINAL-002 created: 2026-08-03 updated: 2026-08-27 owners: @@ -12,14 +13,134 @@ owners: ## Purpose and boundaries -This design preserves the technical source detail for `REQ-UI-QUICK-TERMINAL-001` during migration. +This design owns the shared Quick Chat tab presentation and interaction contract. Quick Chat task +records and Quick Terminal descriptors remain owned by their existing backend services. ## Requirement mapping | Requirement | Design section | | --- | --- | | `REQ-UI-QUICK-TERMINAL-001` | [Migrated source detail](#migrated-source-detail), [Launcher focus return](#launcher-focus-return) | +| `REQ-UI-QUICK-TERMINAL-002` | [Tab order and editing](#tab-order-and-editing) | +## Tab order and editing + +### Components and responsibilities + +- The Quick Chat tab-strip component builds one list of persisted conversation and terminal tabs. + It resolves that list against the saved workspace order before it renders sortable items. +- The Quick Chat UI state owns the optimistic order and serializes user-settings saves. Existing + Quick Chat reconciliation still owns conversation membership. Quick Terminal reconciliation + still owns terminal membership and lifecycle. +- The backend user-settings model stores one order list per user and workspace. The existing boot + payload and partial user-settings update path carry that preference. This feature adds no new + endpoint or WebSocket action. +- Conversation listing uses creation time, then task ID, as its stable baseline. Quick Terminal + descriptors keep their existing `sequence` baseline. The renderer places conversation tabs + before terminal tabs when no saved order changes that baseline. + +### Data and contracts + +User settings add this portable preference: + +```text +quick_chat_tab_order_by_workspace: map +``` + +A tab reference uses a type prefix and a stable persisted identifier: + +- `conversation:` identifies an ordinary or configuration conversation. +- `terminal:` identifies a Quick Terminal descriptor. + +The prefix prevents a future collision between identifier domains. The preference controls only +presentation order. It does not add, remove, rename, or change the lifecycle of a tab. + +The order resolver applies these rules: + +1. Build the stable baseline from current persisted descriptors. +2. Keep each known saved reference once and in its saved position. +3. Ignore saved references that are unknown, duplicated, stale, or in another workspace. +4. Append baseline references that do not appear in the saved order. +5. Append temporary setup tabs after all persisted tabs. Do not save setup tab references. + +Closing a persisted tab removes its reference from the optimistic order. Membership deletion stays +on the existing conversation or terminal path. A later reconciliation also ignores any stale saved +reference, so cleanup and membership deletion do not need one transaction. + +Decision: [Store Quick Chat Tab Order as a User Preference](../../../decisions/2026-08-26-quick-chat-tab-order.md) + +### Control flow + +1. Boot hydration loads conversation sessions, terminal descriptors, and the user's workspace + order preference. +2. The tab strip resolves all three inputs into one rendered list. +3. A completed drag, keyboard move, or menu move updates the list optimistically. +4. The client writes the full persisted-reference list for that workspace through the existing + partial user-settings update API. A serialized save queue prevents an older local save from + overtaking a newer one. +5. The next boot or settings refresh uses the last backend-accepted list. Other clients receive the + preference through the existing user-settings distribution path. + +### Interaction and accessibility + +The sortable strip uses the existing dnd-kit packages. The tab surface is the pointer and touch +drag activator; it has no separate visible drag-handle control. A mouse drag starts after 8 CSS +pixels of movement. A touch drag starts after a 250 millisecond hold with 5 CSS pixels of movement +tolerance. The touch delay preserves horizontal swipe scrolling and ordinary tab activation. The +keyboard sensor uses horizontal sortable coordinates. + +Fine-pointer tabs keep direct close, double-click rename, and context-menu actions. Coarse-pointer +tabs expose a visible action button. Its responsive menu contains **Rename** for conversation tabs, +directional move actions, and **Close**. Directional move actions also provide a precise fallback +when touch drag is difficult. Terminal tabs omit **Rename**. + +Rename mode presents a distinct tab background and border plus a clear input border, background, +caret, focus ring, and selected text. It omits inline **Save** and **Cancel** actions while editing. +The input uses at least 16 CSS pixels of text on coarse-pointer devices to prevent automatic mobile +zoom. Enter commits through the same path as blur. Escape restores the previous name. Blur cannot +cause a second commit. + +The working-state grid spinner and title use an explicit gap of at least 6 CSS pixels. The gap does +not depend on the title text or spinner animation. + +### Mobile design contract + +- **Entry point:** The existing Quick Chat launchers open the same responsive dialog. +- **Phone surface:** The existing full-height dynamic-viewport dialog remains in place. Tablet and + desktop keep the existing large floating dialog. +- **Interaction:** Mouse uses distance-activated drag from the tab surface. Touch uses hold-activated + drag from the tab surface. The visible coarse-pointer menu provides rename, move, and close + actions without hover or a hidden gesture. +- **Scrolling:** The tab strip owns horizontal overflow. The selected chat or terminal owns the + remaining content scroll region. The document must not gain horizontal overflow. +- **Parity:** All viewports use the same order resolver, optimistic state, and persistence path. + Responsive code changes only the action presentation and target sizing. +- **Nearest exemplar:** `sidebar-view-chips.tsx` supplies the horizontal mouse and touch sensor + pattern. The current Quick Chat dialog supplies the full-height mobile surface and scroll owner. + +### Failure and recovery + +A settings save failure keeps the optimistic visual order, active tab, and all membership state. +The client reports the failure through the existing error presentation. A reload can restore the +last backend-accepted order. The next successful move sends a complete current list and can recover +without a special repair endpoint. + +A rename failure keeps the editor available with the user's attempted name and reports the existing +task rename error. It does not close or reorder the tab. Closing or adding a tab while an order save +is pending uses the latest optimistic list as the next serialized save input. + +### Verification contract + +Unit tests cover order resolution, stale and duplicate references, stable append behavior, save +serialization, rename commit and cancel paths, and the active-tab invariant. Component tests assert +the spinner-to-title gap, tab-surface drag activation, and the visually distinct edit state without +inline Save or Cancel controls. + +Desktop Playwright coverage drags mixed chat and terminal tabs, reloads, and checks the same order. +It replaces the current test that expects activity-based reorder after reload. Mobile Playwright +coverage uses the `mobile-chrome` project and a `mobile-*.spec.ts` file. It proves touch reorder or +the visible move fallback, rename discovery, 44 CSS pixel targets, tab-strip overflow containment, +and persistence after reload. ## Migrated source detail ## Why @@ -52,12 +173,12 @@ them without losing work, and return to the most recent terminal without managin - Choosing **New Agent** preserves the current ordinary/configuration setup flow. Choosing **New Terminal** always creates and activates a distinct host-shell terminal, even when another terminal exists. -- Chat and terminal tabs share one horizontal tab strip. Conversation ordering and configuration - indicators remain unchanged; terminal tabs are ordered by creation and use a terminal icon with - workspace-local labels such as `Terminal 1`, `Terminal 2`. -- Renameable conversation tabs expose **Rename** from a context menu on desktop right-click and - the equivalent touch long-press gesture. The existing inline editor and backing-task rename - persistence remain unchanged; terminal labels stay fixed. +- Chat and terminal tabs share one horizontal tab strip. The order resolver above controls their + combined order. Configuration indicators and workspace-local labels such as `Terminal 1` and + `Terminal 2` remain unchanged. +- Renameable conversation tabs expose **Rename** from a fine-pointer context menu and a visible + coarse-pointer action. The backing-task rename persistence remains unchanged. Terminal labels + stay fixed. - Multiple terminal tabs can run concurrently. Input, output, resize, exit, and error state belong to the selected terminal and must not affect sibling terminals. - Switching to another tab or dismissing the shared dialog detaches the terminal presentation but @@ -223,7 +344,7 @@ checks, and Agents-page authorization behavior remain unchanged. - A descriptor create/update failure is visible on the affected tab and never causes a shell to be started without a durable tab record. Sibling tabs remain usable. - Server Quick Chat reconciliation may add or remove persisted conversation tabs, but it must not - discard server-owned terminal tabs or change the active terminal. + discard server-owned terminal tabs, overwrite the saved order, or change the active terminal. - Restoring focus after dialog dismissal must not reopen the sidebar terminal tooltip; pointer hover continues to show it. - A silent-focus marker must stay on the launcher only until focus leaves it. Later keyboard focus @@ -233,6 +354,8 @@ checks, and Agents-page authorization behavior remain unchanged. - Ordinary and configuration chat persistence, cross-device synchronization, renaming, and expiration remain unchanged. +- The backend user-settings record stores tab presentation order per workspace. It follows the + authenticated user across clients. Missing preferences use the stable baseline order. - Terminal tab descriptors survive page reloads, browser restarts, and access from another client for the same authenticated user and workspace. The backend is the durable source of descriptor membership, sequence, lifecycle snapshot, and latest session association. @@ -255,9 +378,14 @@ checks, and Agents-page authorization behavior remain unchanged. plus menu, **THEN** a distinct terminal tab and host-shell session are created and activated. - **GIVEN** the plus menu is open, **WHEN** it renders, **THEN** it shows only **New Agent** under **Agents** and **New Terminal** under **Terminals**; existing tabs remain in the tab strip. -- **GIVEN** a renameable conversation tab is present, **WHEN** the user right-clicks it or - long-presses it and chooses **Rename**, **THEN** the inline editor opens and a submitted name - continues to persist through the existing conversation rename path. +- **GIVEN** a renameable conversation tab is present, **WHEN** the user chooses **Rename** from an + available tab action, **THEN** a clear editor with **Save** and **Cancel** opens and a submitted + name continues to persist through the existing conversation rename path. +- **GIVEN** mixed persisted conversation and terminal tabs, **WHEN** the user moves a tab and + reloads the page, **THEN** all current tabs return in the saved order and the active tab remains + valid. +- **GIVEN** a saved order with stale references, **WHEN** a new tab is discovered, **THEN** stale + references are ignored and the new tab appears after the known persisted tabs. - **GIVEN** two running terminal tabs, **WHEN** the user executes different marker commands in each and switches between them, **THEN** each tab displays only its own PTY output. - **GIVEN** a running terminal tab, **WHEN** the user closes and later reopens the shared dialog, @@ -312,7 +440,7 @@ checks, and Agents-page authorization behavior remain unchanged. existing PTY subscription and rolling buffer behavior. - Task-workspace terminals, repository working-directory selection, or moving terminals between workspaces. -- Terminal tab renaming, drag reordering, split panes, or a command-palette action. +- Terminal tab renaming, split panes, or a command-palette action. - Changing Quick Chat task persistence, configuration-chat uniqueness, or ordinary-chat expiration. - Changing the Agents-page terminal geometry or its stop-on-close lifecycle. @@ -323,3 +451,5 @@ checks, and Agents-page authorization behavior remain unchanged. [Quick Terminal refresh persistence repair](../../../plans/quick-terminal-refresh-persistence/plan.md) [Quick Terminal durable session lifecycle and menu alignment](../../../plans/quick-terminal-durable-lifecycle/plan.md) + +[Quick Chat tab order and editing](../../../plans/quick-chat-tab-order/plan.md)