diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 76c2d04965..ce0d836627 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -328,10 +328,6 @@ func (b bootStateBuilder) quickChatSessions(ctx context.Context, workspaceID str return quickChatBootState{sessions: []map[string]any{}}, nil } taskIDs := taskIDs(tasks) - sessionsByTask, err := b.p.taskSvc.BatchGetSessionsForTasks(ctx, taskIDs) - if err != nil { - return quickChatBootState{}, err - } primaryByTask, err := b.p.taskSvc.GetPrimarySessionInfoForTasks(ctx, taskIDs) if err != nil { return quickChatBootState{}, err @@ -348,13 +344,13 @@ func (b bootStateBuilder) quickChatSessions(ctx context.Context, workspaceID str continue } items = append(items, quickChatBootSession{ - state: mapQuickChatSessionState(task, primary), - lastActivity: quickChatLastActivity(task, sessionsByTask[task.ID]), + state: mapQuickChatSessionState(task, primary), + createdAt: task.CreatedAt, }) taskSessions[primary.ID] = taskdto.FromTaskSession(primary) } sort.SliceStable(items, func(i, j int) bool { - return items[i].lastActivity.After(items[j].lastActivity) + return items[i].createdAt.Before(items[j].createdAt) }) result := make([]map[string]any, 0, len(items)) for _, item := range items { @@ -379,8 +375,8 @@ func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID st } type quickChatBootSession struct { - state map[string]any - lastActivity time.Time + state map[string]any + createdAt time.Time } type quickChatBootState struct { @@ -457,19 +453,6 @@ func quickChatAgentProfileID(task *taskmodels.Task, primary *taskmodels.TaskSess return "" } -func quickChatLastActivity(task *taskmodels.Task, sessions []*taskmodels.TaskSession) time.Time { - last := time.Time{} - if task != nil { - last = task.UpdatedAt - } - for _, session := range sessions { - if session != nil && session.UpdatedAt.After(last) { - last = session.UpdatedAt - } - } - return last -} - func (b bootStateBuilder) addKanbanSnapshotsState( ctx context.Context, state map[string]any, diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index b7cf0b3dc3..7e28c1cda2 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -776,11 +776,11 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { } base := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ - id: "task-old", title: "Older Chat", updatedAt: base.Add(-2 * time.Hour), + id: "task-old", title: "Older Chat", updatedAt: base.Add(-3 * time.Hour), sessionUpdatedAt: base.Add(-90 * time.Minute), agentProfileID: "agent-old", }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ - id: "task-new", title: "Newer Chat", updatedAt: base.Add(-4 * time.Hour), + id: "task-new", title: "Newer Chat", updatedAt: base.Add(-time.Hour), sessionUpdatedAt: base.Add(-10 * time.Minute), agentProfileID: "agent-new", }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ @@ -833,10 +833,10 @@ func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { if len(sessions) != 2 { t.Fatalf("quickChat sessions = %#v, want 2 restored sessions", sessions) } - if got := sessions[0].SessionID; got != "task-new-session" { - t.Fatalf("first restored session = %q, want newest task-new-session", got) + if got := sessions[0].SessionID; got != "task-old-session" { + t.Fatalf("first restored session = %q, want first-created task-old-session", got) } - if sessions[0].AgentProfileID != "agent-new" || sessions[1].AgentProfileID != "agent-old" { + if sessions[0].AgentProfileID != "agent-old" || sessions[1].AgentProfileID != "agent-new" { t.Fatalf("agent profile IDs = %#v", sessions) } if got := decoded.InitialState.TaskSessions.Items["task-new-session"].TaskID; got != "task-new" { diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index ece7447991..2c9e6d1f3a 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -1244,16 +1244,44 @@ func (h *TaskHandlers) httpUnarchiveTask(c *gin.Context) { // httpStartQuickChatRequest is the request body for starting a quick chat session. type httpStartQuickChatRequest struct { - Title string `json:"title,omitempty"` - RepositoryID string `json:"repository_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - Prompt string `json:"prompt,omitempty"` - LocalPath string `json:"local_path,omitempty"` - RepositoryName string `json:"repository_name,omitempty"` - DefaultBranch string `json:"default_branch,omitempty"` - BaseBranch string `json:"base_branch,omitempty"` - LaunchImmediately bool `json:"launch_immediately,omitempty"` + Title string `json:"title,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + Repositories []httpQuickChatRepositoryInput `json:"repositories,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + Prompt string `json:"prompt,omitempty"` + LocalPath string `json:"local_path,omitempty"` + RepositoryName string `json:"repository_name,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + LaunchImmediately bool `json:"launch_immediately,omitempty"` +} + +type httpQuickChatRepositoryInput struct { + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` +} + +func (body *httpStartQuickChatRequest) validateRepositories() error { + hasLegacyRepository := body.RepositoryID != "" || body.LocalPath != "" || + body.RepositoryName != "" || body.DefaultBranch != "" || body.BaseBranch != "" + if len(body.Repositories) > 0 && hasLegacyRepository { + return errors.New("repositories cannot be combined with legacy repository fields") + } + seen := make(map[string]struct{}, len(body.Repositories)) + for _, repo := range body.Repositories { + if repo.RepositoryID == "" { + return errors.New("repository_id is required") + } + if repo.BaseBranch == "" { + return fmt.Errorf("base_branch is required for repository %q", repo.RepositoryID) + } + if _, exists := seen[repo.RepositoryID]; exists { + return fmt.Errorf("repository %q can only be selected once", repo.RepositoryID) + } + seen[repo.RepositoryID] = struct{}{} + } + return nil } // httpStartQuickChatResponse is returned when a quick chat session is created. @@ -1273,6 +1301,16 @@ type quickChatParams struct { // buildQuickChatRepositories builds the repository input list from the request. func (body *httpStartQuickChatRequest) buildRepositories() []service.TaskRepositoryInput { + if len(body.Repositories) > 0 { + repos := make([]service.TaskRepositoryInput, 0, len(body.Repositories)) + for _, repo := range body.Repositories { + repos = append(repos, service.TaskRepositoryInput{ + RepositoryID: repo.RepositoryID, + BaseBranch: repo.BaseBranch, + }) + } + return repos + } if body.RepositoryID == "" && body.LocalPath == "" { return nil } @@ -1291,8 +1329,11 @@ func (body *httpStartQuickChatRequest) resolveParams(workspace *models.Workspace if agentProfileID == "" && workspace.DefaultAgentProfileID != nil { agentProfileID = *workspace.DefaultAgentProfileID } + repos := body.buildRepositories() executorID := body.ExecutorID - if executorID == "" && workspace.DefaultExecutorID != nil { + if len(repos) > 0 { + executorID = models.ExecutorIDWorktree + } else if executorID == "" && workspace.DefaultExecutorID != nil { executorID = *workspace.DefaultExecutorID } @@ -1313,7 +1354,7 @@ func (body *httpStartQuickChatRequest) resolveParams(workspace *models.Workspace agentProfileID: agentProfileID, executorID: executorID, title: title, - repos: body.buildRepositories(), + repos: repos, metadata: metadata, } } @@ -1326,6 +1367,10 @@ func (h *TaskHandlers) httpStartQuickChat(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } + if err := body.validateRepositories(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } ctx := c.Request.Context() 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 9bfeaf94ff..8e417c949f 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -57,6 +57,119 @@ func (m *captureOrchestrator) EnsureSession(_ context.Context, _ string, _ ...or return nil, nil } +func TestQuickChatRequestBuildRepositoriesAcceptsPluralInput(t *testing.T) { + var body httpStartQuickChatRequest + require.NoError(t, json.Unmarshal([]byte(`{ + "repositories": [ + {"repository_id":"repo-1","base_branch":"main"}, + {"repository_id":"repo-2","base_branch":"develop"} + ] + }`), &body)) + + repos := body.buildRepositories() + + require.Len(t, repos, 2) + assert.Equal(t, service.TaskRepositoryInput{RepositoryID: "repo-1", BaseBranch: "main"}, repos[0]) + assert.Equal(t, service.TaskRepositoryInput{RepositoryID: "repo-2", BaseBranch: "develop"}, repos[1]) +} + +func TestQuickChatResolveParamsForcesWorktreeForRepositoryContext(t *testing.T) { + defaultExecutor := models.ExecutorIDLocal + body := httpStartQuickChatRequest{ + AgentProfileID: "profile-1", + RepositoryID: "repo-1", + BaseBranch: "main", + } + + params := body.resolveParams(&models.Workspace{DefaultExecutorID: &defaultExecutor}) + + assert.Equal(t, models.ExecutorIDWorktree, params.executorID) +} + +type quickChatHandlerRepo struct { + mockRepository + taskRepos []*models.TaskRepository +} + +func (r *quickChatHandlerRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) { + defaultAgent := "profile-1" + defaultExecutor := models.ExecutorIDLocal + return &models.Workspace{ + ID: id, + DefaultAgentProfileID: &defaultAgent, + DefaultExecutorID: &defaultExecutor, + }, nil +} + +func (r *quickChatHandlerRepo) GetRepository(_ context.Context, id string) (*models.Repository, error) { + return &models.Repository{ID: id, WorkspaceID: "ws-1", Name: id, DefaultBranch: "main"}, nil +} + +func (r *quickChatHandlerRepo) CreateTaskRepository(_ context.Context, taskRepo *models.TaskRepository) error { + r.taskRepos = append(r.taskRepos, taskRepo) + return nil +} + +func (r *quickChatHandlerRepo) ListTaskRepositories(_ context.Context, _ string) ([]*models.TaskRepository, error) { + return r.taskRepos, nil +} + +func newQuickChatHandlerForTest(t *testing.T) (*TaskHandlers, *captureOrchestrator) { + t.Helper() + log := newTestLogger(t) + repo := &quickChatHandlerRepo{} + 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{}) + orch := &captureOrchestrator{} + return &TaskHandlers{service: svc, orchestrator: orch, logger: log}, orch +} + +func TestHTTPStartQuickChatRejectsInvalidRepositoryShapes(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + body string + }{ + { + name: "mixed legacy and plural repositories", + body: `{"repository_id":"repo-legacy","repositories":[{"repository_id":"repo-1","base_branch":"main"}]}`, + }, + { + name: "same repository twice", + body: `{"repositories":[{"repository_id":"repo-1","base_branch":"main"},{"repository_id":"repo-1","base_branch":"develop"}]}`, + }, + { + name: "empty repository id", + body: `{"repositories":[{"repository_id":"","base_branch":"main"}]}`, + }, + { + name: "empty base branch", + body: `{"repositories":[{"repository_id":"repo-1","base_branch":""}]}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.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)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + + h.httpStartQuickChat(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Empty(t, orch.requests) + }) + } +} + // 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/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx index 5b63eea923..6d85fa7ed5 100644 --- a/apps/web/components/quick-chat/quick-chat-content.tsx +++ b/apps/web/components/quick-chat/quick-chat-content.tsx @@ -84,7 +84,7 @@ export const QuickChatContent = memo(function QuickChatContent({ return (
-
+
); diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index 22cecbe515..e68b873537 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -1,16 +1,18 @@ "use client"; -import { memo } from "react"; +import { memo, type CSSProperties } from "react"; import { useShallow } from "zustand/react/shallow"; import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; -import { IconLoader2, IconMessageCircle, IconPlus } from "@tabler/icons-react"; +import { IconPlus } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { PassthroughTerminal } from "@/components/task/passthrough-terminal"; import { QuickChatContent } from "./quick-chat-content"; import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog"; import { QuickChatTabItem } from "./quick-chat-tab-item"; +import { QuickChatSetup } from "./quick-chat-setup"; import { useQuickChatModal } from "./use-quick-chat-modal"; +import { useQuickChatWidth } from "@/hooks/use-quick-chat-width"; type QuickChatModalProps = { workspaceId: string; @@ -51,20 +53,47 @@ function QuickChatTabs({ /> ); })} +
-
); } +function QuickChatResizeHandle({ + edge, + onMouseDown, +}: { + edge: "left" | "right"; + onMouseDown: (event: React.MouseEvent) => void; +}) { + return ( + + ); +} + function useIsQuickChatPassthrough(sessionId: string) { return useAppStore( useShallow((s) => { @@ -91,65 +120,13 @@ function QuickChatSessionView({ sessionId }: { sessionId: string }) { return ; } -function AgentPickerView({ - onSelectAgent, - pendingAgentId, -}: { - onSelectAgent: (agentId: string) => void; - pendingAgentId: string | null; -}) { - const agentProfiles = useAppStore((s) => s.agentProfiles.items) ?? []; - const isLoading = pendingAgentId !== null; - - return ( -
-
-
-

Choose an agent to start chatting

-

- Select an AI agent to begin your conversation -

-
-
- {agentProfiles.map((profile) => { - const isPending = pendingAgentId === profile.id; - return ( - - ); - })} -
-
-
- ); -} - export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: QuickChatModalProps) { const { isOpen, sessions, activeSessionId, sessionToClose, + setupKey, activeSessionNeedsAgent, pendingAgentId, setActiveQuickChatSession, @@ -161,16 +138,21 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic handleConfirmClose, handleRename, } = useQuickChatModal(workspaceId); + const { width, leftResizeHandleProps, rightResizeHandleProps } = useQuickChatWidth(); + const hasCreatedChat = sessions.some((session) => session.sessionId !== ""); return ( <> Quick Chat + + )} {activeSessionNeedsAgent && ( - + handleOpenChange(false)} + /> )} diff --git a/apps/web/components/quick-chat/quick-chat-setup.test.tsx b/apps/web/components/quick-chat/quick-chat-setup.test.tsx new file mode 100644 index 0000000000..1ca9d37ba0 --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-setup.test.tsx @@ -0,0 +1,109 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QuickChatSetup } from "./quick-chat-setup"; + +let defaultAgentId = ""; +const AGENT_SELECTOR_TEST_ID = "agent-profile-selector"; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + agentProfiles: { items: [{ id: "agent-a" }, { id: "agent-b" }] }, + workspaces: { + items: [{ id: "workspace-1", default_agent_profile_id: defaultAgentId }], + }, + }), +})); + +vi.mock("@/components/task-create-dialog-options", () => ({ + useAgentProfileOptions: () => [], +})); + +vi.mock("@/components/task-create-dialog-selectors", () => ({ + AgentSelector: ({ + value, + onValueChange, + triggerClassName, + }: { + value: string; + onValueChange: (id: string) => void; + triggerClassName?: string; + }) => ( + + ), +})); + +vi.mock("@/components/task-create-dialog-workspace-repo-chips", () => ({ + WorkspaceRepoChips: () => null, +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/components/task-create-dialog-repositories-state", () => ({ + useRepositoriesState: () => ({ + repositories: [], + addRepository: vi.fn(), + removeRepository: vi.fn(), + updateRepository: vi.fn(), + }), +})); + +vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ + useRepositories: () => ({ repositories: [], isLoading: false }), +})); + +const props = { + workspaceId: "workspace-1", + showIntroduction: true, + pendingAgentId: null, + onStart: vi.fn(), + onCancel: vi.fn(), +}; + +beforeEach(() => { + defaultAgentId = ""; + vi.clearAllMocks(); +}); + +afterEach(cleanup); + +describe("QuickChatSetup default agent", () => { + it("renders the agent selector with a visible field border", () => { + render(); + + expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).className).toContain("border-input"); + }); + + it("uses a default agent that arrives after the setup mounts", () => { + const { rerender } = render(); + expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("Select agent"); + + defaultAgentId = "agent-a"; + rerender(); + + expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("agent-a"); + }); + + it("preserves an explicit selection when the workspace default changes", () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId(AGENT_SELECTOR_TEST_ID)); + expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("agent-b"); + + defaultAgentId = "agent-a"; + rerender(); + + expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("agent-b"); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-setup.tsx b/apps/web/components/quick-chat/quick-chat-setup.tsx new file mode 100644 index 0000000000..fd04db6358 --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-setup.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconInfoCircle, IconLoader2 } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { AgentSelector } from "@/components/task-create-dialog-selectors"; +import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; +import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips"; +import { useRepositoriesState } from "@/components/task-create-dialog-repositories-state"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; +import type { QuickChatRepositoryInput } from "@/lib/api/domains/workspace-api"; +import type { AgentProfileOption } from "@/lib/state/slices"; +import type { Repository } from "@/lib/types/http"; + +type QuickChatSetupProps = { + workspaceId: string; + showIntroduction: boolean; + pendingAgentId: string | null; + onStart: (agentId: string, repositories: QuickChatRepositoryInput[]) => void; + onCancel: () => void; +}; + +function repositoryAddState(isLoading: boolean, repositoryCount: number, rowCount: number) { + if (isLoading) return { canAddMore: false, addHint: "Loading repositories" }; + if (repositoryCount === 0) { + return { canAddMore: false, addHint: "No repositories available in this workspace" }; + } + if (rowCount >= repositoryCount) { + return { canAddMore: false, addHint: "All workspace repositories are already added" }; + } + return { canAddMore: true, addHint: undefined }; +} + +function QuickChatIntroduction({ show }: { show: boolean }) { + if (!show) return null; + return ( +
+

+ Chat with an agent about an idea, question, or codebase. +

+

Quick chats stay outside your task board.

+
+ ); +} + +function AgentField({ + profiles, + value, + disabled, + onChange, +}: { + profiles: AgentProfileOption[]; + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const options = useAgentProfileOptions(profiles); + return ( +
+
+

+ Agent profile +

+

+ Choose the agent for this conversation. +

+
+ 0 ? "Select agent" : "No agents available"} + triggerClassName="h-11 w-full justify-between border border-input bg-background px-3 shadow-xs hover:bg-accent/50 data-[state=open]:border-ring data-[state=open]:ring-[2px] data-[state=open]:ring-ring/35" + popoverPortal + /> +
+ ); +} + +type RepositoryFieldProps = { + workspaceId: string; + repositories: Repository[]; + rows: ReturnType["repositories"]; + canAddMore: boolean; + addHint?: string; + onAdd: () => void; + onRemove: (key: string) => void; + onRepositoryChange: (key: string, value: string) => void; + onBranchChange: (key: string, value: string) => void; +}; + +function RepositoryField(props: RepositoryFieldProps) { + return ( +
+
+
+

+ Repositories (optional) +

+

+ Add repository context to focus on specific code and branches. +

+
+ +
+
+ +
+
+ ); +} + +function RepositoryContextHelp() { + return ( + + + + + + Kandev uses an isolated worktree from the selected branch. Uncommitted local changes are not + included. + + + ); +} + +function SetupFooter({ + isStarting, + startDisabled, + onCancel, + onStart, +}: { + isStarting: boolean; + startDisabled: boolean; + onCancel: () => void; + onStart: () => void; +}) { + return ( +
+ + +
+ ); +} + +export function QuickChatSetup({ + workspaceId, + showIntroduction, + pendingAgentId, + onStart, + onCancel, +}: QuickChatSetupProps) { + const agentProfiles = useAppStore((state) => state.agentProfiles.items ?? []); + const defaultAgentId = useAppStore( + (state) => + state.workspaces.items.find((workspace) => workspace.id === workspaceId) + ?.default_agent_profile_id ?? "", + ); + const [agentProfileId, setAgentProfileId] = useState(defaultAgentId); + useEffect(() => { + if (!defaultAgentId) return; + setAgentProfileId((current) => current || defaultAgentId); + }, [defaultAgentId]); + const { repositories, isLoading } = useRepositories(workspaceId, true); + const repoState = useRepositoriesState(); + const isStarting = pendingAgentId !== null; + const hasIncompleteRow = repoState.repositories.some((row) => !row.repositoryId || !row.branch); + const { canAddMore, addHint } = repositoryAddState( + isLoading, + repositories.length, + repoState.repositories.length, + ); + const handleRepositoryChange = useCallback( + (key: string, repositoryId: string) => { + repoState.updateRepository(key, { repositoryId, localPath: undefined, branch: "" }); + }, + [repoState], + ); + const handleBranchChange = useCallback( + (key: string, branch: string) => repoState.updateRepository(key, { branch }), + [repoState], + ); + const selectedRepositories = useMemo( + () => + repoState.repositories + .filter((row) => row.repositoryId && row.branch) + .map((row) => ({ repository_id: row.repositoryId as string, base_branch: row.branch })), + [repoState.repositories], + ); + const startDisabled = !agentProfileId || hasIncompleteRow || isStarting; + return ( +
+
+
+
+

Quick Chat

+ +
+ + +
+
+ onStart(agentProfileId, selectedRepositories)} + /> +
+ ); +} 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 377a627c8d..be5705d1ff 100644 --- a/apps/web/components/quick-chat/quick-chat-tab-item.tsx +++ b/apps/web/components/quick-chat/quick-chat-tab-item.tsx @@ -62,6 +62,7 @@ export const QuickChatTabItem = memo(function QuickChatTabItem({ return (
; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: ReturnType) => unknown) => + selector(mockAppState), +})); vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mockToast }), @@ -18,12 +24,25 @@ vi.mock("@/lib/api/domains/kanban-api", () => ({ deleteTask: (...args: unknown[]) => mockDeleteTask(...args), })); -import { useAgentSelection } from "./use-quick-chat-modal"; +import { useAgentSelection, useQuickChatModal } from "./use-quick-chat-modal"; const WORKSPACE_ID = "ws-1"; type MockStore = Parameters[1]; +function makeAppState() { + return { + quickChat: { isOpen: true, sessions: [] as Array<{ sessionId: string }>, activeSessionId: "" }, + closeQuickChat: vi.fn(), + closeQuickChatSession: vi.fn(), + setActiveQuickChatSession: vi.fn(), + renameQuickChatSession: vi.fn(), + openQuickChat: vi.fn(), + agentProfiles: { items: [] }, + taskSessions: { items: {} }, + }; +} + function makeStore(overrides: Partial = {}): MockStore { return { isOpen: true, @@ -49,26 +68,67 @@ function flushPromises() { beforeEach(() => { vi.clearAllMocks(); + mockAppState = makeAppState(); +}); + +describe("useQuickChatModal — setup lifecycle", () => { + it("removes a blank placeholder when dismissed from an active session", () => { + mockAppState.quickChat.sessions = [{ sessionId: "" }, { sessionId: "session-1" }]; + mockAppState.quickChat.activeSessionId = "session-1"; + const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID)); + + act(() => result.current.handleOpenChange(false)); + + expect(mockAppState.closeQuickChatSession).toHaveBeenCalledWith(""); + expect(mockAppState.closeQuickChat).toHaveBeenCalledTimes(1); + }); + + it("changes the setup key when a fresh blank chat is requested", () => { + const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID)); + + expect(result.current.setupKey).toBe(0); + act(() => result.current.handleNewChat()); + + expect(result.current.setupKey).toBe(1); + expect(mockAppState.openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID); + }); }); describe("useAgentSelection — happy path", () => { it("opens the chat and clears pending state when the request resolves", async () => { const store = makeStore(); mockStartQuickChat.mockResolvedValue({ task_id: "task-a", session_id: "sess-a" }); - const onSuccess = vi.fn(); - const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); await act(async () => { - await result.current.handleSelectAgent("agent-a", onSuccess); + await result.current.handleSelectAgent("agent-a"); }); expect(store.openQuickChat).toHaveBeenCalledWith("sess-a", WORKSPACE_ID, "agent-a"); expect(store.renameQuickChatSession).toHaveBeenCalledWith("sess-a", expect.any(String)); - expect(onSuccess).toHaveBeenCalledTimes(1); expect(mockDeleteTask).not.toHaveBeenCalled(); expect(result.current.pendingAgentId).toBeNull(); }); + + it("forwards ordered repository context to the start request", async () => { + const store = makeStore(); + mockStartQuickChat.mockResolvedValue({ task_id: "task-a", session_id: "sess-a" }); + const repositories = [ + { repository_id: "repo-front", base_branch: "main" }, + { repository_id: "repo-back", base_branch: "develop" }, + ]; + + const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); + + await act(async () => { + await result.current.handleSelectAgent("agent-a", repositories); + }); + + expect(mockStartQuickChat).toHaveBeenCalledWith( + WORKSPACE_ID, + expect.objectContaining({ repositories }), + ); + }); }); describe("useAgentSelection — supersession", () => { @@ -82,29 +142,25 @@ describe("useAgentSelection — supersession", () => { .mockImplementationOnce(() => firstPromise) .mockResolvedValueOnce({ task_id: "task-b", session_id: "sess-b" }); - const onSuccessA = vi.fn(); - const onSuccessB = vi.fn(); const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); // Click A — request hangs. act(() => { - void result.current.handleSelectAgent("agent-a", onSuccessA); + void result.current.handleSelectAgent("agent-a"); }); expect(result.current.pendingAgentId).toBe("agent-a"); // Click B — supersedes A. await act(async () => { - await result.current.handleSelectAgent("agent-b", onSuccessB); + await result.current.handleSelectAgent("agent-b"); }); - expect(onSuccessB).toHaveBeenCalledTimes(1); expect(store.openQuickChat).toHaveBeenCalledWith("sess-b", WORKSPACE_ID, "agent-b"); - // Now A resolves — its onSuccess must NOT fire and the orphan task is deleted. + // Now A resolves — its orphan task is deleted instead of opening a stale session. await act(async () => { resolveFirst({ task_id: "task-a", session_id: "sess-a" }); await flushPromises(); }); - expect(onSuccessA).not.toHaveBeenCalled(); expect(mockDeleteTask).toHaveBeenCalledWith("task-a"); expect(store.openQuickChat).not.toHaveBeenCalledWith( "sess-a", @@ -113,7 +169,7 @@ describe("useAgentSelection — supersession", () => { ); }); - it("reset() during in-flight request: resolved task is deleted, onSuccess not called", async () => { + it("reset() during in-flight request deletes the resolved task", async () => { const store = makeStore(); let resolveStart!: (v: { task_id: string; session_id: string }) => void; mockStartQuickChat.mockImplementationOnce( @@ -123,11 +179,10 @@ describe("useAgentSelection — supersession", () => { }), ); - const onSuccess = vi.fn(); const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); act(() => { - void result.current.handleSelectAgent("agent-a", onSuccess); + void result.current.handleSelectAgent("agent-a"); }); expect(result.current.pendingAgentId).toBe("agent-a"); @@ -141,7 +196,6 @@ describe("useAgentSelection — supersession", () => { resolveStart({ task_id: "task-a", session_id: "sess-a" }); await flushPromises(); }); - expect(onSuccess).not.toHaveBeenCalled(); expect(store.openQuickChat).not.toHaveBeenCalled(); expect(mockDeleteTask).toHaveBeenCalledWith("task-a"); }); @@ -161,7 +215,7 @@ describe("useAgentSelection — error handling", () => { const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); act(() => { - void result.current.handleSelectAgent("agent-a", vi.fn()); + void result.current.handleSelectAgent("agent-a"); }); act(() => { result.current.reset(); @@ -181,7 +235,7 @@ describe("useAgentSelection — error handling", () => { const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store)); await act(async () => { - await result.current.handleSelectAgent("agent-a", vi.fn()); + await result.current.handleSelectAgent("agent-a"); }); expect(mockToast).toHaveBeenCalledWith( 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 a2da0e70f5..fb22552419 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; -import { startQuickChat } from "@/lib/api/domains/workspace-api"; +import { startQuickChat, type QuickChatRepositoryInput } from "@/lib/api/domains/workspace-api"; async function deleteQuickChatTask(taskId: string) { const { deleteTask } = await import("@/lib/api/domains/kanban-api"); @@ -31,13 +31,19 @@ function useQuickChatStore() { type QuickChatStore = ReturnType; /** POSTs to start a quick-chat session and returns the response. */ -async function startQuickChatForAgent(workspaceId: string, agentId: string, store: QuickChatStore) { +async function startQuickChatForAgent( + workspaceId: string, + agentId: string, + store: QuickChatStore, + repositories: QuickChatRepositoryInput[], +) { const agent = store.agentProfiles.find((p) => p.id === agentId); const sessionCount = store.sessions.filter((s) => s.sessionId !== "").length + 1; const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`; const response = await startQuickChat(workspaceId, { agent_profile_id: agentId, title: initialName, + repositories: repositories.length > 0 ? repositories : undefined, }); return { sessionId: response.session_id, name: initialName, taskId: response.task_id }; } @@ -65,11 +71,11 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) { }, []); const handleSelectAgent = useCallback( - async (agentId: string, onSuccess: () => void) => { + async (agentId: string, repositories: QuickChatRepositoryInput[] = []) => { const requestId = ++latestRequestId.current; setPendingAgentId(agentId); try { - const result = await startQuickChatForAgent(workspaceId, agentId, store); + const result = await startQuickChatForAgent(workspaceId, agentId, store, repositories); if (latestRequestId.current !== requestId) { // A newer pick superseded us — the backend already booted this // agent, so delete the orphan task. Best-effort: ignore failures. @@ -81,7 +87,6 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) { if (store.activeSessionId === "") store.closeQuickChatSession(""); store.openQuickChat(result.sessionId, workspaceId, agentId); store.renameQuickChatSession(result.sessionId, result.name); - onSuccess(); } catch (error) { if (latestRequestId.current !== requestId) return; toast({ @@ -104,7 +109,7 @@ export function useAgentSelection(workspaceId: string, store: QuickChatStore) { export function useQuickChatModal(workspaceId: string) { const { toast } = useToast(); const store = useQuickChatStore(); - const [showAgentPicker, setShowAgentPicker] = useState(false); + const [setupKey, setSetupKey] = useState(0); const [sessionToClose, setSessionToClose] = useState(null); const { pendingAgentId, @@ -116,8 +121,10 @@ export function useQuickChatModal(workspaceId: string) { (open: boolean) => { if (open) return; reset(); + if (store.sessions.some((session) => session.sessionId === "")) { + store.closeQuickChatSession(""); + } store.closeQuickChat(); - setShowAgentPicker(false); }, [store, reset], ); @@ -127,11 +134,13 @@ export function useQuickChatModal(workspaceId: string) { // instead of yanking the user back to that session. const handleNewChat = useCallback(() => { reset(); + setSetupKey((key) => key + 1); store.openQuickChat("", workspaceId); }, [reset, store, workspaceId]); const handleSelectAgent = useCallback( - (agentId: string) => doSelectAgent(agentId, () => setShowAgentPicker(false)), + (agentId: string, repositories: QuickChatRepositoryInput[] = []) => + doSelectAgent(agentId, repositories), [doSelectAgent], ); @@ -187,7 +196,8 @@ export function useQuickChatModal(workspaceId: string) { sessions: store.sessions, activeSessionId: store.activeSessionId, sessionToClose, - activeSessionNeedsAgent: store.activeSessionId === "" || showAgentPicker, + setupKey, + activeSessionNeedsAgent: store.activeSessionId === "", pendingAgentId, setActiveQuickChatSession, setSessionToClose, diff --git a/apps/web/components/task-create-dialog-repo-chips.test.tsx b/apps/web/components/task-create-dialog-repo-chips.test.tsx index 7916a25045..1d028ee22c 100644 --- a/apps/web/components/task-create-dialog-repo-chips.test.tsx +++ b/apps/web/components/task-create-dialog-repo-chips.test.tsx @@ -29,11 +29,13 @@ vi.mock("./task-create-dialog-remote-repo-chip", () => ({ })); import { RepoChipsRow } from "./task-create-dialog-repo-chips"; +import { WorkspaceRepoChips } from "./task-create-dialog-workspace-repo-chips"; afterEach(cleanup); const REPO_FRONT_ID = "repo-front"; const REPO_BACK_ID = "repo-back"; +const REPO_CHIP_TRIGGER = "repo-chip-trigger"; function makeRepo(id: string, name: string): Repository { return { @@ -300,7 +302,7 @@ describe("RepoChipsRow", () => { onRowBranchChange={NOOP} />, ); - fireEvent.click(screen.getByTestId("repo-chip-trigger")); + fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER)); expect(screen.getByText("frontend")).toBeTruthy(); expect(screen.getByText("~/projects/local-project")).toBeTruthy(); expect(screen.queryByText("frontend-dup")).toBeNull(); @@ -327,7 +329,7 @@ describe("RepoChipsRow", () => { onRowBranchChange={NOOP} />, ); - fireEvent.click(screen.getByTestId("repo-chip-trigger")); + fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER)); fireEvent.click(screen.getByText("~/projects/local-project")); expect(onRowRepositoryChange).toHaveBeenCalledWith("r0", "/home/me/projects/local-project"); }); @@ -402,3 +404,45 @@ describe("RepoChipsRow", () => { mockBranches.value = { branches: [], isLoading: false }; }); }); + +describe("WorkspaceRepoChips", () => { + const repositories = [makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]; + const rows = [ + row({ key: "r0", repositoryId: REPO_FRONT_ID, branch: "main" }), + row({ key: "r1", branch: "develop" }), + ]; + + function renderWorkspaceChips(allowDuplicateRepositories: boolean) { + return renderInProvider( + , + ); + } + + it("excludes repositories already selected by another quick-chat row", () => { + renderWorkspaceChips(false); + + fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); + + expect(screen.queryByRole("option", { name: /^frontend/ })).toBeNull(); + expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy(); + }); + + it("keeps task creation's same-repository different-branch option", () => { + renderWorkspaceChips(true); + + fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); + + expect(screen.getByRole("option", { name: /^frontend/ })).toBeTruthy(); + expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task-create-dialog-repo-chips.tsx b/apps/web/components/task-create-dialog-repo-chips.tsx index ec4683cfbb..13266505e8 100644 --- a/apps/web/components/task-create-dialog-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-repo-chips.tsx @@ -1,31 +1,14 @@ "use client"; -import { useMemo } from "react"; -import { IconPlus, IconX, IconCode, IconGitBranch, IconGitFork } from "@tabler/icons-react"; -import { cn, formatUserHomePath } from "@/lib/utils"; -import { Badge } from "@kandev/ui/badge"; +import { IconGitFork } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches"; -import type { LocalRepository, Repository } from "@/lib/types/http"; -import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types"; -import { scoreBranch } from "@/lib/utils/branch-filter"; -import { scoreRepo } from "@/lib/utils/repo-filter"; -import { - Pill, - sortBranches, - branchToOption, - computeBranchPlaceholder, - type PillOption, -} from "@/components/task-create-dialog-pill"; +import type { Repository } from "@/lib/types/http"; +import type { DialogFormState } from "@/components/task-create-dialog-types"; import { RemoteRepoChipsRow } from "@/components/task-create-dialog-remote-repo-chips"; import { FolderPicker } from "@/components/folder-picker"; import { SourceModeSwitch } from "@/components/task-create-dialog-source-mode"; -import { - computeBranchPrefix, - computeBranchTooltip, - computeBranchDisabledReason, -} from "@/components/task-create-dialog-branch-utils"; -import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect"; +import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips"; type RepoChipsRowProps = { fs: DialogFormState; @@ -195,14 +178,20 @@ function ModeBody({ ); } return ( - void; - onRowBranchChange: (key: string, value: string) => void; - lastUsedBranch?: string | null; - userSettingsLoaded?: boolean; -}) { - return ( - <> - {fs.repositories.map((row) => ( - onRowRepositoryChange(row.key, value)} - onBranchChange={(value) => onRowBranchChange(row.key, value)} - onRemove={() => fs.removeRepository(row.key)} - /> - ))} - {freshBranchToggle} - - - - - - - {addHint ?? "Add another repository"} - - - ); -} - function FreshBranchToggle({ enabled, onToggle, @@ -355,322 +251,3 @@ function computeAddHint(canAddMore: boolean, workspaceRepoCount: number): string if (workspaceRepoCount === 0) return "No repositories available in this workspace"; return "All workspace repositories are already added"; } - -/** - * Returns the set of repo ids/paths that would create a literal duplicate - * (same repo + same branch) of an *existing* row if `currentRow` adopted - * them — used to hide already-claimed pairings from the repo dropdown. - * - * Multi-branch tasks are supported: the same repo can appear across multiple - * rows as long as each row's branch differs. Rows with empty branches don't - * collide yet, so they don't contribute to the exclusion set. - * - * Same-row entries are skipped so the current row's own pick remains - * selectable; without that, after the user pairs (repo, branch) the chip - * would suddenly render its current repo as unavailable. - */ -function collectExactDuplicateRepoIds(rows: TaskRepoRow[], currentRow: TaskRepoRow): Set { - const ids = new Set(); - for (const r of rows) { - if (r.key === currentRow.key) continue; - if (!r.branch || r.branch !== currentRow.branch) continue; - if (r.repositoryId) ids.add(r.repositoryId); - if (r.localPath) ids.add(r.localPath); - } - return ids; -} - -type RepoChipProps = { - row: TaskRepoRow; - /** Required for path-based branch loading on discovered rows. */ - workspaceId: string | null; - repositories: Repository[]; - discoveredRepositories: LocalRepository[]; - /** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */ - excludedRepoIds: Set; - /** - * Lock the branch pill regardless of branch availability. Used for the - * local executor where the user's actual checkout dictates the branch - * (and changing it would mutate their working tree). Fresh-branch mode - * unlocks it because we're explicitly creating a new branch from a base. - */ - branchLocked?: boolean; - /** - * When set, seed row.branch with this value (for an empty row). Used by - * the local-executor flow to surface the workspace's current ref — either - * a branch name like "main" or, on detached HEAD, the short commit SHA - * returned by the backend. The chip displays it verbatim ("current: main" - * or "current: 4fbc5d7"); on submit the backend's skip-when-equal check - * matches the same SHA so it's a no-op. - * - * When unset, the chip falls back to the existing last-used / preferred- - * default autoselect (main / master / develop, etc.). - */ - preferredDefaultBranch?: string; - lastUsedBranch?: string | null; - userSettingsLoaded?: boolean; - /** - * True while preferredDefaultBranch is being resolved. Renders a - * "Loading branch…" placeholder so the chip doesn't briefly show an empty - * state in the window between dialog open and local-status resolving. - */ - preferredDefaultBranchLoading?: boolean; - /** - * Muted text shown before the branch value to qualify intent: - * - "current: " — local exec, picked branch == workspace current - * - "will switch to: " — local exec, picked branch != workspace current - * - "from: " — worktree / non-local exec (picked branch is the base) - * Empty when there's no branch value yet (chip shows the "branch" - * placeholder unprefixed). - */ - branchPrefix?: string; - onRepositoryChange: (value: string) => void; - onBranchChange: (value: string) => void; - onRemove: () => void; -}; - -function useRepoChipData({ - row, - workspaceId, - repositories, - discoveredRepositories, - excludedRepoIds, - onBranchChange, - preferredDefaultBranch, - preferredDefaultBranchLoading, - lastUsedBranch, - userSettingsLoaded, -}: Pick< - RepoChipProps, - | "row" - | "workspaceId" - | "repositories" - | "discoveredRepositories" - | "excludedRepoIds" - | "onBranchChange" - | "preferredDefaultBranch" - | "preferredDefaultBranchLoading" - | "lastUsedBranch" - | "userSettingsLoaded" ->) { - const filteredRepos = useMemo( - () => repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId), - [repositories, excludedRepoIds, row.repositoryId], - ); - const filteredDiscovered = useMemo(() => { - const workspaceRepoPaths = new Set( - filteredRepos - .map((r) => r.local_path) - .filter(Boolean) - .map((path: string) => normalizeRepoPath(path)), - ); - return discoveredRepositories.filter( - (r) => - !workspaceRepoPaths.has(normalizeRepoPath(r.path)) && - (!excludedRepoIds.has(r.path) || r.path === row.localPath), - ); - }, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]); - - const branchSource = useMemo(() => { - if (!workspaceId) return null; - if (row.repositoryId) { - return { kind: "id", workspaceId, repositoryId: row.repositoryId }; - } - if (row.localPath) { - return { kind: "path", workspaceId, path: row.localPath }; - } - return null; - }, [workspaceId, row.repositoryId, row.localPath]); - const { - branches, - isLoading: branchesLoading, - refresh: refreshBranches, - } = useBranches(branchSource, !!branchSource); - useRepoBranchAutoselect({ - branchSource, - branchesLoading, - branches, - rowBranch: row.branch, - onBranchChange, - preferredDefaultBranch, - preferredDefaultBranchLoading, - lastUsedBranch, - userSettingsLoaded, - }); - - const repoOptions: PillOption[] = useMemo( - () => [ - ...filteredRepos.map((r) => ({ - value: r.id, - label: r.name, - keywords: [r.name, r.local_path, formatUserHomePath(r.local_path)].filter( - (s): s is string => !!s, - ), - renderLabel: () => renderWorkspaceRepoOption(r), - })), - ...filteredDiscovered.map((r) => ({ - value: r.path, - label: leafSegment(r.path), - keywords: [r.path, formatUserHomePath(r.path)], - renderLabel: () => renderDiscoveredRepoOption(r.path), - })), - ], - [filteredRepos, filteredDiscovered], - ); - const branchOptions: PillOption[] = useMemo( - () => sortBranches(branches).map(branchToOption), - [branches], - ); - return { repoOptions, branchOptions, branchesLoading, refreshBranches }; -} - -function computeRepoChipDisplay( - row: TaskRepoRow, - repositories: Repository[], - discoveredRepositories: LocalRepository[], -) { - const workspaceRepo = repositories.find((r) => r.id === row.repositoryId); - const discoveredRepo = discoveredRepositories.find((r) => r.path === row.localPath); - const repoLabel = workspaceRepo?.name ?? discoveredRepo?.path?.split("/").pop() ?? ""; - const repoPath = workspaceRepo?.local_path || discoveredRepo?.path || ""; - const repoTooltip = repoPath ? `Repository · ${formatUserHomePath(repoPath)}` : "Repository"; - return { repoLabel, repoTooltip }; -} - -function RepoChip({ - row, - workspaceId, - repositories, - discoveredRepositories, - excludedRepoIds, - branchLocked, - preferredDefaultBranch, - preferredDefaultBranchLoading, - lastUsedBranch, - userSettingsLoaded, - branchPrefix, - onRepositoryChange, - onBranchChange, - onRemove, -}: RepoChipProps) { - const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({ - row, - workspaceId, - repositories, - discoveredRepositories, - excludedRepoIds, - onBranchChange, - preferredDefaultBranch, - preferredDefaultBranchLoading, - lastUsedBranch, - userSettingsLoaded, - }); - const { repoLabel, repoTooltip } = computeRepoChipDisplay( - row, - repositories, - discoveredRepositories, - ); - const branchValue = preferredDefaultBranchLoading ? "" : row.branch; - const hasRepo = !!(row.repositoryId || row.localPath); - const branchPlaceholder = computeBranchPlaceholder( - hasRepo, - branchesLoading || !!preferredDefaultBranchLoading, - branchOptions.length, - ); - - return ( - - } - value={repoLabel} - placeholder="repository" - options={repoOptions} - onSelect={onRepositoryChange} - searchPlaceholder="Search repositories..." - emptyMessage="No repositories" - testId="repo-chip-trigger" - tooltip={repoTooltip} - filter={scoreRepo} - flat - /> - } - value={branchValue} - placeholder={branchPlaceholder} - prefix={branchPrefix} - options={branchOptions} - onSelect={onBranchChange} - disabled={branchLocked || !hasRepo || branchesLoading || branchOptions.length === 0} - disabledReason={computeBranchDisabledReason({ - branchLocked: !!branchLocked, - hasRepo, - branchesLoading, - optionCount: branchOptions.length, - })} - searchPlaceholder="Search branches..." - emptyMessage="No branches" - testId="branch-chip-trigger" - tooltip={computeBranchTooltip(branchPrefix)} - onRefresh={refreshBranches} - refreshing={branchesLoading} - filter={scoreBranch} - flat - /> - - - - - Remove repository - - - ); -} - -function normalizeRepoPath(path: string): string { - return path.replace(/\\/g, "/").replace(/\/+$/g, ""); -} - -function renderWorkspaceRepoOption(repo: Repository) { - const display = repo.local_path ? formatUserHomePath(repo.local_path) : ""; - return ( - - {repo.name} - {display ? ( - {display} - ) : null} - - ); -} - -function renderDiscoveredRepoOption(path: string) { - const display = formatUserHomePath(path); - return ( - - - {leafSegment(path)} - {display} - - - on disk - - - ); -} - -function leafSegment(path: string): string { - const cleaned = path.replace(/\\/g, "/").replace(/\/+$/g, ""); - const idx = cleaned.lastIndexOf("/"); - return idx >= 0 ? cleaned.slice(idx + 1) : cleaned; -} diff --git a/apps/web/components/task-create-dialog-state.test.ts b/apps/web/components/task-create-dialog-state.test.ts index f25db185df..c7624a6a20 100644 --- a/apps/web/components/task-create-dialog-state.test.ts +++ b/apps/web/components/task-create-dialog-state.test.ts @@ -421,7 +421,7 @@ describe("useDialogFormState — title autofill from first row GitHub URL info", expect(result.current.taskName).toBe("PR #99: Another PR"); }); - it("does NOT autofill from a non-first row's PR info", () => { + it("autofills from the first non-empty row when an earlier placeholder is empty", () => { const SECOND_PR_URL = "https://github.com/acme/api/pull/99"; prInfoMap.set(SECOND_PR_URL, { prHeadBranch: "feature/y", @@ -441,7 +441,7 @@ describe("useDialogFormState — title autofill from first row GitHub URL info", act(() => { result.current.updateRemoteRepo(secondKey!, { url: SECOND_PR_URL }); }); - expect(result.current.taskName).toBe(""); + expect(result.current.taskName).toBe("PR #99: Second PR"); }); }); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 311bd363be..9344128e2c 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -429,12 +429,13 @@ export function useDialogFormState( useRemoteReposSeedEffect(ghUrl.useRemote, remoteRepos.remoteRepos, remoteRepos.setRemoteRepos); - // Title autofill: the first remote-repo row's PR info seeds the task title - // when the user hasn't typed one. Keyed only off row 0 — adding more PR - // rows later never overwrites the title. - useTitleAutofillFromFirstRowGitHubInfo({ + // Title autofill follows the first populated remote-repo row. Empty + // placeholders do not prevent a later pasted PR or issue from suggesting + // its title. + const primaryRemoteUrl = remoteRepos.remoteRepos.find((row) => row.url.trim())?.url ?? ""; + useTitleAutofillFromPrimaryGitHubInfo({ open: open && ghUrl.useRemote, - firstRowUrl: remoteRepos.remoteRepos[0]?.url ?? "", + primaryRemoteUrl, prInfoByUrl, taskName: form.taskName, setTaskName: form.setTaskName, @@ -464,7 +465,7 @@ export function useDialogFormState( } /** - * Seeds the task title from the first Remote row's GitHub URL info (when present) + * Seeds the task title from the first populated Remote row's GitHub URL info * the first time it arrives, and only when the user hasn't typed a title. * Subsequent info changes for the same URL don't overwrite — the * lastAutoFilledRef tracks our own writes so re-pasting a different GitHub @@ -476,30 +477,32 @@ export function useDialogFormState( * autofill for the current URL until the URL itself changes. */ const USER_CLEARED_SENTINEL = "\0cleared"; -function useTitleAutofillFromFirstRowGitHubInfo(args: { +function useTitleAutofillFromPrimaryGitHubInfo(args: { open: boolean; - firstRowUrl: string; + primaryRemoteUrl: string; prInfoByUrl: ReturnType; taskName: string; setTaskName: (v: string) => void; setHasTitle: (v: boolean) => void; }) { - const { open, firstRowUrl, prInfoByUrl, taskName, setTaskName, setHasTitle } = args; + const { open, primaryRemoteUrl, prInfoByUrl, taskName, setTaskName, setHasTitle } = args; const lastAutoFilledRef = useRef(""); const lastUrlRef = useRef(""); // Re-read latest info on every render; cheap because the cache is memoized // by URL inside the hook. - const suggested = firstRowUrl ? prInfoByUrl.info(firstRowUrl)?.suggestedTitle : undefined; + const suggested = primaryRemoteUrl + ? prInfoByUrl.info(primaryRemoteUrl)?.suggestedTitle + : undefined; // Reset ownership-tracking when the URL changes — switching to a different // PR URL grants a fresh autofill opportunity even if the user previously // cleared an autofill on the prior URL. useEffect(() => { - if (firstRowUrl !== lastUrlRef.current) { - lastUrlRef.current = firstRowUrl; + if (primaryRemoteUrl !== lastUrlRef.current) { + lastUrlRef.current = primaryRemoteUrl; lastAutoFilledRef.current = ""; } - }, [firstRowUrl]); + }, [primaryRemoteUrl]); useEffect(() => { // Detect the "user cleared an auto-filled title" transition. Once we see diff --git a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx new file mode 100644 index 0000000000..5567125b11 --- /dev/null +++ b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { useMemo } from "react"; +import { IconPlus, IconX, IconCode, IconGitBranch } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches"; +import type { LocalRepository, Repository } from "@/lib/types/http"; +import type { TaskRepoRow } from "@/components/task-create-dialog-types"; +import { cn, formatUserHomePath } from "@/lib/utils"; +import { scoreBranch } from "@/lib/utils/branch-filter"; +import { scoreRepo } from "@/lib/utils/repo-filter"; +import { + Pill, + sortBranches, + branchToOption, + computeBranchPlaceholder, + type PillOption, +} from "@/components/task-create-dialog-pill"; +import { + computeBranchPrefix, + computeBranchTooltip, + computeBranchDisabledReason, +} from "@/components/task-create-dialog-branch-utils"; +import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect"; + +/** + * Renders the list of repo chips plus the trailing "+ add repository" + * button. Extracted from RepoChipsRow so the parent stays under the + * function-length cap; logic is unchanged. + */ +export function WorkspaceRepoChips({ + rows, + repositories, + discoveredRepositories, + workspaceId, + branchLocked, + isLocalExecutor, + currentLocalBranch, + currentLocalBranchLoading, + freshBranchEnabled, + canAddMore, + addHint, + addLabel, + allowDuplicateRepositories = true, + freshBranchToggle, + onAdd, + onRemove, + onRowRepositoryChange, + onRowBranchChange, + lastUsedBranch, + userSettingsLoaded, +}: { + rows: TaskRepoRow[]; + repositories: Repository[]; + discoveredRepositories?: LocalRepository[]; + workspaceId: string | null; + branchLocked?: boolean; + isLocalExecutor?: boolean; + currentLocalBranch?: string; + currentLocalBranchLoading?: boolean; + freshBranchEnabled?: boolean; + canAddMore: boolean; + addHint?: string; + addLabel?: string; + allowDuplicateRepositories?: boolean; + freshBranchToggle?: React.ReactNode; + onAdd: () => void; + onRemove: (key: string) => void; + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; + lastUsedBranch?: string | null; + userSettingsLoaded?: boolean; +}) { + return ( + <> + {rows.map((row) => ( + onRowRepositoryChange(row.key, value)} + onBranchChange={(value) => onRowBranchChange(row.key, value)} + onRemove={() => onRemove(row.key)} + /> + ))} + {freshBranchToggle} + + + + + + + {addHint ?? "Add another repository"} + + + ); +} + +/** + * Returns the repo ids/paths that should be hidden from `currentRow` based on + * the caller's repository-duplication mode. + * + * When duplicates are allowed, only an exact (repo, branch) pairing is + * excluded, so task creation can target multiple branches from one repo. + * When duplicates are disabled, quick chat excludes the entire repository + * after another row selects it, regardless of branch. + * + * Same-row entries are skipped so the current row's own pick remains + * selectable; without that, after the user pairs (repo, branch) the chip + * would suddenly render its current repo as unavailable. + */ +function collectExcludedRepoIds( + rows: TaskRepoRow[], + currentRow: TaskRepoRow, + allowDuplicateRepositories: boolean, +): Set { + const ids = new Set(); + for (const r of rows) { + if (r.key === currentRow.key) continue; + if (allowDuplicateRepositories && (!r.branch || r.branch !== currentRow.branch)) continue; + if (r.repositoryId) ids.add(r.repositoryId); + if (r.localPath) ids.add(r.localPath); + } + return ids; +} + +type RepoChipProps = { + row: TaskRepoRow; + /** Required for path-based branch loading on discovered rows. */ + workspaceId: string | null; + repositories: Repository[]; + discoveredRepositories: LocalRepository[]; + /** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */ + excludedRepoIds: Set; + /** + * Lock the branch pill regardless of branch availability. Used for the + * local executor where the user's actual checkout dictates the branch + * (and changing it would mutate their working tree). Fresh-branch mode + * unlocks it because we're explicitly creating a new branch from a base. + */ + branchLocked?: boolean; + /** + * When set, seed row.branch with this value (for an empty row). Used by + * the local-executor flow to surface the workspace's current ref — either + * a branch name like "main" or, on detached HEAD, the short commit SHA + * returned by the backend. The chip displays it verbatim ("current: main" + * or "current: 4fbc5d7"); on submit the backend's skip-when-equal check + * matches the same SHA so it's a no-op. + * + * When unset, the chip falls back to the existing last-used / preferred- + * default autoselect (main / master / develop, etc.). + */ + preferredDefaultBranch?: string; + lastUsedBranch?: string | null; + userSettingsLoaded?: boolean; + /** + * True while preferredDefaultBranch is being resolved. Renders a + * "Loading branch…" placeholder so the chip doesn't briefly show an empty + * state in the window between dialog open and local-status resolving. + */ + preferredDefaultBranchLoading?: boolean; + /** + * Muted text shown before the branch value to qualify intent: + * - "current: " — local exec, picked branch == workspace current + * - "will switch to: " — local exec, picked branch != workspace current + * - "from: " — worktree / non-local exec (picked branch is the base) + * Empty when there's no branch value yet (chip shows the "branch" + * placeholder unprefixed). + */ + branchPrefix?: string; + onRepositoryChange: (value: string) => void; + onBranchChange: (value: string) => void; + onRemove: () => void; +}; + +function useRepoChipData({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + onBranchChange, + preferredDefaultBranch, + preferredDefaultBranchLoading, + lastUsedBranch, + userSettingsLoaded, +}: Pick< + RepoChipProps, + | "row" + | "workspaceId" + | "repositories" + | "discoveredRepositories" + | "excludedRepoIds" + | "onBranchChange" + | "preferredDefaultBranch" + | "preferredDefaultBranchLoading" + | "lastUsedBranch" + | "userSettingsLoaded" +>) { + const filteredRepos = useMemo( + () => repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId), + [repositories, excludedRepoIds, row.repositoryId], + ); + const filteredDiscovered = useMemo(() => { + const workspaceRepoPaths = new Set( + filteredRepos + .map((r) => r.local_path) + .filter(Boolean) + .map((path: string) => normalizeRepoPath(path)), + ); + return discoveredRepositories.filter( + (r) => + !workspaceRepoPaths.has(normalizeRepoPath(r.path)) && + (!excludedRepoIds.has(r.path) || r.path === row.localPath), + ); + }, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]); + + const branchSource = useMemo(() => { + if (!workspaceId) return null; + if (row.repositoryId) { + return { kind: "id", workspaceId, repositoryId: row.repositoryId }; + } + if (row.localPath) { + return { kind: "path", workspaceId, path: row.localPath }; + } + return null; + }, [workspaceId, row.repositoryId, row.localPath]); + const { + branches, + isLoading: branchesLoading, + refresh: refreshBranches, + } = useBranches(branchSource, !!branchSource); + useRepoBranchAutoselect({ + branchSource, + branchesLoading, + branches, + rowBranch: row.branch, + onBranchChange, + preferredDefaultBranch, + preferredDefaultBranchLoading, + lastUsedBranch, + userSettingsLoaded, + }); + + const repoOptions: PillOption[] = useMemo( + () => [ + ...filteredRepos.map((r) => ({ + value: r.id, + label: r.name, + keywords: [r.name, r.local_path, formatUserHomePath(r.local_path)].filter( + (s): s is string => !!s, + ), + renderLabel: () => renderWorkspaceRepoOption(r), + })), + ...filteredDiscovered.map((r) => ({ + value: r.path, + label: leafSegment(r.path), + keywords: [r.path, formatUserHomePath(r.path)], + renderLabel: () => renderDiscoveredRepoOption(r.path), + })), + ], + [filteredRepos, filteredDiscovered], + ); + const branchOptions: PillOption[] = useMemo( + () => sortBranches(branches).map(branchToOption), + [branches], + ); + return { repoOptions, branchOptions, branchesLoading, refreshBranches }; +} + +function computeRepoChipDisplay( + row: TaskRepoRow, + repositories: Repository[], + discoveredRepositories: LocalRepository[], +) { + const workspaceRepo = repositories.find((r) => r.id === row.repositoryId); + const discoveredRepo = discoveredRepositories.find((r) => r.path === row.localPath); + const repoLabel = workspaceRepo?.name ?? (discoveredRepo ? leafSegment(discoveredRepo.path) : ""); + const repoPath = workspaceRepo?.local_path || discoveredRepo?.path || ""; + const repoTooltip = repoPath ? `Repository · ${formatUserHomePath(repoPath)}` : "Repository"; + return { repoLabel, repoTooltip }; +} + +function RepoChip({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + branchLocked, + preferredDefaultBranch, + preferredDefaultBranchLoading, + lastUsedBranch, + userSettingsLoaded, + branchPrefix, + onRepositoryChange, + onBranchChange, + onRemove, +}: RepoChipProps) { + const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + onBranchChange, + preferredDefaultBranch, + preferredDefaultBranchLoading, + lastUsedBranch, + userSettingsLoaded, + }); + const { repoLabel, repoTooltip } = computeRepoChipDisplay( + row, + repositories, + discoveredRepositories, + ); + const branchValue = preferredDefaultBranchLoading ? "" : row.branch; + const hasRepo = !!(row.repositoryId || row.localPath); + const branchPlaceholder = computeBranchPlaceholder( + hasRepo, + branchesLoading || !!preferredDefaultBranchLoading, + branchOptions.length, + ); + + return ( + + } + value={repoLabel} + placeholder="repository" + options={repoOptions} + onSelect={onRepositoryChange} + searchPlaceholder="Search repositories..." + emptyMessage="No repositories" + testId="repo-chip-trigger" + tooltip={repoTooltip} + filter={scoreRepo} + flat + /> + } + value={branchValue} + placeholder={branchPlaceholder} + prefix={branchPrefix} + options={branchOptions} + onSelect={onBranchChange} + disabled={branchLocked || !hasRepo || branchesLoading || branchOptions.length === 0} + disabledReason={computeBranchDisabledReason({ + branchLocked: !!branchLocked, + hasRepo, + branchesLoading, + optionCount: branchOptions.length, + })} + searchPlaceholder="Search branches..." + emptyMessage="No branches" + testId="branch-chip-trigger" + tooltip={computeBranchTooltip(branchPrefix)} + onRefresh={refreshBranches} + refreshing={branchesLoading} + filter={scoreBranch} + flat + /> + + + + + Remove repository + + + ); +} + +function normalizeRepoPath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/g, ""); +} + +function renderWorkspaceRepoOption(repo: Repository) { + const display = repo.local_path ? formatUserHomePath(repo.local_path) : ""; + return ( + + {repo.name} + {display ? ( + {display} + ) : null} + + ); +} + +function renderDiscoveredRepoOption(path: string) { + const display = formatUserHomePath(path); + return ( + + + {leafSegment(path)} + {display} + + + on disk + + + ); +} + +function leafSegment(path: string): string { + const cleaned = path.replace(/\\/g, "/").replace(/\/+$/g, ""); + const idx = cleaned.lastIndexOf("/"); + return idx >= 0 ? cleaned.slice(idx + 1) : cleaned; +} diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index d3c775983a..44d1486921 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -33,6 +33,7 @@ import { useExecutorEnvironmentAvailability } from "@/hooks/domains/session/use- import { useToast } from "@/components/toast-provider"; import type { DiffComment } from "@/lib/diff/types"; import type { useChatPanelState } from "./use-chat-panel-state"; +import { cn } from "@/lib/utils"; const PLAN_CONTEXT_PATH = "plan:context"; @@ -365,6 +366,7 @@ type ChatInputAreaProps = { /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ hidePlanMode?: boolean; placeholderOverride?: string; + surfaceClassName?: string; }; function useExecutorUnavailable(taskId: string | null, sessionId: string | null) { @@ -417,6 +419,7 @@ export function ChatInputArea({ minimalToolbar, hidePlanMode, placeholderOverride, + surfaceClassName, }: ChatInputAreaProps) { const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled, todoItems } = panelState; @@ -429,7 +432,10 @@ export function ChatInputArea({ ); const { implementPlanHandler, proceedStepName, proceed, isMoving } = planActions; return ( -
+
{ const dialog = page.getByRole("dialog", { name: "Quick Chat" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); - const agentPicker = dialog.getByText("Choose an agent to start chatting"); - if (!(await agentPicker.isVisible({ timeout: 1_000 }).catch(() => false))) { + const setup = dialog.getByTestId("quick-chat-setup"); + if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) { await dialog.getByLabel("Start new chat").click(); } - await expect(agentPicker).toBeVisible({ timeout: 5_000 }); + await expect(setup).toBeVisible({ timeout: 5_000 }); - const agentCard = dialog - .locator("button") - .filter({ has: page.locator(".rounded-md.border") }) - .first(); - await expect(agentCard).toBeVisible({ timeout: 5_000 }); - await agentCard.click(); + const agentSelector = dialog.getByTestId("agent-profile-selector"); + if ( + await agentSelector + .getByText("Select agent", { exact: false }) + .isVisible() + .catch(() => false) + ) { + await agentSelector.click(); + await page.getByRole("option").first().click(); + } + await dialog.getByTestId("quick-chat-start").click(); // Wait for chat input to appear AND become editable. Eager init means the // agent starts during the picker → tab transition; the input is briefly diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts new file mode 100644 index 0000000000..9dcd6d32ff --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "../../fixtures/test-base"; + +async function assertNoHorizontalOverflow(page: import("@playwright/test").Page) { + const dimensions = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1); +} + +test.describe("Quick Chat repository context on mobile", () => { + test("selects an agent and repository branch without hidden actions", async ({ testPage }) => { + await testPage.goto("/"); + await testPage.waitForLoadState("networkidle"); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await testPage.keyboard.press(`${modifier}+Shift+q`); + + const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); + await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByTestId("quick-chat-resize-left")).not.toBeVisible(); + await expect(dialog.getByTestId("quick-chat-resize-right")).not.toBeVisible(); + await expect( + dialog.getByText("Chat with an agent about an idea, question, or codebase."), + ).toBeVisible(); + + const agentSelector = dialog.getByTestId("agent-profile-selector"); + if (await agentSelector.getByText("Select agent", { exact: false }).isVisible()) { + await agentSelector.click(); + await testPage.getByRole("option").first().click(); + } + await dialog.getByTestId("add-repository").click(); + await dialog.getByTestId("repo-chip-trigger").click(); + await testPage.getByRole("option").first().click(); + await expect(dialog.getByTestId("branch-chip-trigger")).toContainText("main", { + timeout: 10_000, + }); + await assertNoHorizontalOverflow(testPage); + + await dialog.getByTestId("quick-chat-start").click(); + await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 }); + await assertNoHorizontalOverflow(testPage); + }); +}); diff --git a/apps/web/e2e/tests/chat/quick-chat.spec.ts b/apps/web/e2e/tests/chat/quick-chat.spec.ts index 3a14e0026b..8c6475ab5f 100644 --- a/apps/web/e2e/tests/chat/quick-chat.spec.ts +++ b/apps/web/e2e/tests/chat/quick-chat.spec.ts @@ -1,4 +1,6 @@ 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"; import { attachAvailableCommandsCapture } from "../../helpers/ws-capture"; @@ -6,7 +8,7 @@ import { attachAvailableCommandsCapture } from "../../helpers/ws-capture"; * Quick Chat E2E tests: basic flow, enhance prompt, queued messages, multi-tab. */ -async function openQuickChatWithAgent(page: Page): Promise { +async function openQuickChatSetup(page: Page): Promise { await page.goto("/"); await page.waitForLoadState("networkidle"); @@ -18,20 +20,31 @@ async function openQuickChatWithAgent(page: Page): Promise { const dialog = page.getByRole("dialog", { name: "Quick Chat" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); - // If a stale session tab is showing, click "+" to start a fresh agent picker. - const agentPicker = dialog.getByText("Choose an agent to start chatting"); - if (!(await agentPicker.isVisible({ timeout: 1_000 }).catch(() => false))) { + // If a stale session tab is showing, click "+" to start a fresh setup form. + const setup = dialog.getByTestId("quick-chat-setup"); + if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) { await dialog.getByLabel("Start new chat").click(); } - await expect(agentPicker).toBeVisible({ timeout: 5_000 }); + await expect(setup).toBeVisible({ timeout: 5_000 }); + return dialog; +} - // Click the first agent profile card. - const agentCard = dialog - .locator("button") - .filter({ has: page.locator(".rounded-md.border") }) - .first(); - await expect(agentCard).toBeVisible({ timeout: 5_000 }); - await agentCard.click(); +async function selectAgentIfNeeded(dialog: Locator, page: Page) { + const selector = dialog.getByTestId("agent-profile-selector"); + if ( + await selector + .getByText("Select agent", { exact: false }) + .isVisible() + .catch(() => false) + ) { + await selector.click(); + await page.getByRole("option").first().click(); + } +} + +async function startQuickChatFromSetup(dialog: Locator, page: Page) { + await selectAgentIfNeeded(dialog, page); + await dialog.getByTestId("quick-chat-start").click(); // Wait for chat input to appear AND become editable. Eager init means the // agent starts during the HTTP request, so the input is briefly disabled @@ -39,7 +52,11 @@ async function openQuickChatWithAgent(page: Page): Promise { const editor = dialog.locator(".tiptap.ProseMirror"); await expect(editor).toBeVisible({ timeout: 15_000 }); await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 }); +} +async function openQuickChatWithAgent(page: Page): Promise { + const dialog = await openQuickChatSetup(page); + await startQuickChatFromSetup(dialog, page); return dialog; } @@ -59,7 +76,167 @@ async function sendQuickChatMessage(dialog: Locator, page: Page, text: string) { }).toPass({ timeout: 30_000, intervals: [250, 500, 1_000] }); } +async function waitForQuickChatWidth(dialog: Locator) { + await expect + .poll(() => + dialog.evaluate((element) => { + const preferred = Number.parseFloat( + getComputedStyle(element).getPropertyValue("--quick-chat-width"), + ); + return Math.abs(element.getBoundingClientRect().width - preferred); + }), + ) + .toBeLessThan(2); +} + test.describe("Quick Chat", () => { + test("resizes from either edge, restores width, and keeps tab actions adjacent", async ({ + testPage, + }) => { + const dialog = await openQuickChatSetup(testPage); + await waitForQuickChatWidth(dialog); + const initialBox = await dialog.boundingBox(); + expect(initialBox).not.toBeNull(); + + const rightHandle = dialog.getByTestId("quick-chat-resize-right"); + await expect(rightHandle).toBeVisible(); + const rightBox = await rightHandle.boundingBox(); + expect(rightBox).not.toBeNull(); + await testPage.mouse.move( + rightBox!.x + rightBox!.width / 2, + rightBox!.y + rightBox!.height / 2, + ); + await testPage.mouse.down(); + await expect.poll(() => testPage.evaluate(() => document.body.style.cursor)).toBe("ew-resize"); + await testPage.mouse.move( + rightBox!.x + rightBox!.width / 2 + 50, + rightBox!.y + rightBox!.height / 2, + ); + await testPage.mouse.up(); + await waitForQuickChatWidth(dialog); + + const rightResizedBox = await dialog.boundingBox(); + expect(rightResizedBox!.width).toBeGreaterThan(initialBox!.width + 80); + + const leftHandle = dialog.getByTestId("quick-chat-resize-left"); + const leftBox = await leftHandle.boundingBox(); + expect(leftBox).not.toBeNull(); + await leftHandle.hover(); + const leftHighlightBox = await leftHandle.locator("span").boundingBox(); + expect(leftHighlightBox).not.toBeNull(); + expect(leftHighlightBox!.x).toBeCloseTo(rightResizedBox!.x, 0); + await testPage.mouse.move(leftBox!.x + leftBox!.width / 2, leftBox!.y + leftBox!.height / 2); + await testPage.mouse.down(); + await testPage.mouse.move( + leftBox!.x + leftBox!.width / 2 - 40, + leftBox!.y + leftBox!.height / 2, + ); + await testPage.mouse.up(); + await waitForQuickChatWidth(dialog); + + const finalBox = await dialog.boundingBox(); + const finalPreferredWidth = await dialog.evaluate((element) => + Number.parseFloat(getComputedStyle(element).getPropertyValue("--quick-chat-width")), + ); + expect(finalBox!.width).toBeGreaterThan(rightResizedBox!.width + 50); + expect(finalBox!.x + finalBox!.width / 2).toBeCloseTo( + (await testPage.evaluate(() => window.innerWidth)) / 2, + 0, + ); + + const tab = dialog.getByTestId("quick-chat-tab").last(); + const newChat = dialog.getByLabel("Start new chat"); + const tabBox = await tab.boundingBox(); + const newChatBox = await newChat.boundingBox(); + expect(newChatBox!.x - (tabBox!.x + tabBox!.width)).toBeLessThanOrEqual(8); + + const setupSurfaces = await dialog.evaluate((element) => { + const setup = element.querySelector('[data-testid="quick-chat-setup"]'); + const footer = element.querySelector('[data-testid="quick-chat-setup-footer"]'); + return { + dialog: getComputedStyle(element).backgroundColor, + setup: setup ? getComputedStyle(setup).backgroundColor : null, + footer: footer ? getComputedStyle(footer).backgroundColor : null, + }; + }); + expect(setupSurfaces.setup).toBe(setupSurfaces.dialog); + expect(setupSurfaces.footer).toBe(setupSurfaces.setup); + + await startQuickChatFromSetup(dialog, testPage); + const surfaces = await dialog.evaluate((element) => { + const messages = element.querySelector('[data-testid="quick-chat-messages"]'); + const input = element.querySelector('[data-testid="chat-input-area"]'); + return { + dialog: getComputedStyle(element).backgroundColor, + messages: messages ? getComputedStyle(messages).backgroundColor : null, + input: input ? getComputedStyle(input).backgroundColor : null, + }; + }); + expect(surfaces.messages).toBe(surfaces.dialog); + expect(surfaces.input).toBe(surfaces.messages); + + await testPage.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + await testPage.reload(); + await testPage.waitForLoadState("networkidle"); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await testPage.keyboard.press(`${modifier}+Shift+q`); + await expect(dialog).toBeVisible(); + await waitForQuickChatWidth(dialog); + const restoredBox = await dialog.boundingBox(); + const restoredPreferredWidth = await dialog.evaluate((element) => + Number.parseFloat(getComputedStyle(element).getPropertyValue("--quick-chat-width")), + ); + expect(restoredPreferredWidth).toBe(finalPreferredWidth); + expect(Math.abs(restoredBox!.width - finalBox!.width)).toBeLessThan(2); + }); + + test("explains quick chat and starts with repository context", async ({ + testPage, + seedData, + backend, + }) => { + const sourceRepo = path.join(backend.tmpDir, "repos", "e2e-repo"); + const contextBranch = "quick-chat-context-branch"; + execFileSync("git", ["branch", "-f", contextBranch], { cwd: sourceRepo }); + try { + const dialog = await openQuickChatSetup(testPage); + await expect(dialog.getByTestId("quick-chat-introduction")).toContainText( + "Chat with an agent about an idea, question, or codebase.", + ); + await expect( + dialog.getByText("Add repository context to focus on specific code and branches."), + ).toBeVisible(); + await selectAgentIfNeeded(dialog, testPage); + + await dialog.getByTestId("add-repository").click(); + await dialog.getByTestId("repo-chip-trigger").click(); + await testPage.getByRole("option").first().click(); + await dialog.getByTestId("branch-chip-trigger").click(); + await testPage.getByRole("option", { name: contextBranch }).click(); + + const startRequest = testPage.waitForRequest( + (request) => request.url().includes("/quick-chat") && request.method() === "POST", + ); + await dialog.getByTestId("quick-chat-start").click(); + const payload = (await startRequest).postDataJSON() as { + repositories?: Array<{ repository_id: string; base_branch: string }>; + }; + expect(payload.repositories).toEqual([ + { repository_id: seedData.repositoryId, base_branch: contextBranch }, + ]); + await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 }); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: sourceRepo, + encoding: "utf8", + }).trim(), + ).toBe("main"); + } finally { + execFileSync("git", ["branch", "-D", contextBranch], { cwd: sourceRepo }); + } + }); + test("opens quick chat, selects agent, sends message and receives response", async ({ testPage, }) => { @@ -186,20 +363,10 @@ test.describe("Quick Chat", () => { const newChatBtn = dialog.getByLabel("Start new chat"); await newChatBtn.click(); - // Agent picker should appear. - await expect(dialog.getByText("Choose an agent to start chatting")).toBeVisible({ - timeout: 5_000, - }); - - // Select agent for the new tab. - const agentCard = dialog - .locator("button") - .filter({ has: testPage.locator(".rounded-md.border") }) - .first(); - await agentCard.click(); - - // Wait for chat input in new tab. - await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 15_000 }); + // Setup should appear without the first-use introduction once a chat exists. + await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 5_000 }); + await expect(dialog.getByTestId("quick-chat-introduction")).not.toBeVisible(); + await startQuickChatFromSetup(dialog, testPage); // Send a message in the second tab using script mode. await sendQuickChatMessage(dialog, testPage, 'e2e:message("second tab response")'); diff --git a/apps/web/e2e/tests/chat/slash-command-composer.spec.ts b/apps/web/e2e/tests/chat/slash-command-composer.spec.ts index 451705b51e..6c12ab87f4 100644 --- a/apps/web/e2e/tests/chat/slash-command-composer.spec.ts +++ b/apps/web/e2e/tests/chat/slash-command-composer.spec.ts @@ -65,18 +65,23 @@ async function openQuickChatWithAgent(page: Page): Promise { const dialog = page.getByRole("dialog", { name: "Quick Chat" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); - const agentPicker = dialog.getByText("Choose an agent to start chatting"); - if (!(await agentPicker.isVisible({ timeout: 1_000 }).catch(() => false))) { + const setup = dialog.getByTestId("quick-chat-setup"); + if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) { await dialog.getByLabel("Start new chat").click(); } - await expect(agentPicker).toBeVisible({ timeout: 5_000 }); + await expect(setup).toBeVisible({ timeout: 5_000 }); - const agentCard = dialog - .locator("button") - .filter({ has: page.locator(".rounded-md.border") }) - .first(); - await expect(agentCard).toBeVisible({ timeout: 5_000 }); - await agentCard.click(); + const agentSelector = dialog.getByTestId("agent-profile-selector"); + if ( + await agentSelector + .getByText("Select agent", { exact: false }) + .isVisible() + .catch(() => false) + ) { + await agentSelector.click(); + await page.getByRole("option").first().click(); + } + await dialog.getByTestId("quick-chat-start").click(); const editor = chatEditor(dialog); await expect(editor).toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts b/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts index 25fd598285..964b499b8f 100644 --- a/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts +++ b/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts @@ -186,6 +186,9 @@ test.describe("Task creation from Remote tab (chip picker)", () => { // survives the truncation, so assert on that. const trigger = testPage.getByTestId("remote-repo-chip-trigger").first(); await expect(trigger).toContainText("paste-repo", { timeout: 5_000 }); + await expect(testPage.getByTestId("remote-branch-chip-trigger").first()).toContainText("main", { + timeout: 10_000, + }); await testPage.getByTestId("task-title-input").fill("Remote paste happy path"); await testPage.getByTestId("task-description-input").fill("test"); diff --git a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts index a34669e5bd..93a4302b58 100644 --- a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts +++ b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts @@ -3,7 +3,7 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; -import { TimeoutError, type Page } from "@playwright/test"; +import { errors, type Page } from "@playwright/test"; // --------------------------------------------------------------------------- // Helpers shared across TUI passthrough tests @@ -113,7 +113,7 @@ test.describe("Terminal agent (TUI passthrough)", () => { await session.waitForPassthroughLoading(); } catch (error) { // Keep tolerance for "didn't appear in time", but preserve real failures. - if (!(error instanceof TimeoutError)) throw error; + if (!(error instanceof errors.TimeoutError)) throw error; } // Once connected, loading overlay disappears and terminal content is visible diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.test.ts b/apps/web/hooks/domains/workspace/use-repository-branches.test.ts new file mode 100644 index 0000000000..4407c87ab0 --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repository-branches.test.ts @@ -0,0 +1,69 @@ +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const listBranchesMock = vi.fn(); +const setRepositoryBranchesMock = vi.fn(); +const setRepositoryBranchesLoadingMock = vi.fn(); + +const mockState = { + repositoryBranches: { + itemsByRepositoryId: {} as Record, + loadedByRepositoryId: {} as Record, + loadingByRepositoryId: {} as Record, + }, + setRepositoryBranches: setRepositoryBranchesMock, + setRepositoryBranchesLoading: setRepositoryBranchesLoadingMock, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), +})); + +vi.mock("@/lib/api", () => ({ + listBranches: (...args: unknown[]) => listBranchesMock(...args), + listRepositoryBranches: vi.fn(), +})); + +import { useBranches, type BranchSource } from "./use-repository-branches"; + +const WORKSPACE_ID = "workspace-1"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useBranches", () => { + it("loads a new repository while the prior repository request is still pending", async () => { + listBranchesMock + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce({ branches: [{ name: "main", type: "remote" }] }); + + const sourceA: BranchSource = { + kind: "id", + workspaceId: WORKSPACE_ID, + repositoryId: "repo-a", + }; + const sourceB: BranchSource = { + kind: "id", + workspaceId: WORKSPACE_ID, + repositoryId: "repo-b", + }; + const { rerender } = renderHook(({ source }: { source: BranchSource }) => useBranches(source), { + initialProps: { source: sourceA }, + }); + + await waitFor(() => + expect(listBranchesMock).toHaveBeenCalledWith(WORKSPACE_ID, { + repositoryId: "repo-a", + }), + ); + rerender({ source: sourceB }); + + await waitFor(() => + expect(listBranchesMock).toHaveBeenCalledWith(WORKSPACE_ID, { + repositoryId: "repo-b", + }), + ); + }); +}); diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.ts b/apps/web/hooks/domains/workspace/use-repository-branches.ts index 7a37dd2dd3..f37681584d 100644 --- a/apps/web/hooks/domains/workspace/use-repository-branches.ts +++ b/apps/web/hooks/domains/workspace/use-repository-branches.ts @@ -54,12 +54,12 @@ export function useBranches(source: BranchSource | null, enabled = true): UseBra ); const setRepositoryBranches = useAppStore((state) => state.setRepositoryBranches); const setRepositoryBranchesLoading = useAppStore((state) => state.setRepositoryBranchesLoading); - const inFlightRef = useRef(false); + const inFlightKeysRef = useRef>(new Set()); useEffect(() => { if (!enabled || !source) return; - if (isLoaded || inFlightRef.current) return; - inFlightRef.current = true; + if (isLoaded || inFlightKeysRef.current.has(key)) return; + inFlightKeysRef.current.add(key); setRepositoryBranchesLoading(key, true); const promise = @@ -71,7 +71,7 @@ export function useBranches(source: BranchSource | null, enabled = true): UseBra .then((response) => setRepositoryBranches(key, response.branches)) .catch(() => setRepositoryBranches(key, [])) .finally(() => { - inFlightRef.current = false; + inFlightKeysRef.current.delete(key); setRepositoryBranchesLoading(key, false); }); // eslint-disable-next-line react-hooks/exhaustive-deps -- key encodes source identity; listing every field re-fires on every render diff --git a/apps/web/hooks/use-quick-chat-width.test.ts b/apps/web/hooks/use-quick-chat-width.test.ts new file mode 100644 index 0000000000..eaf80fd103 --- /dev/null +++ b/apps/web/hooks/use-quick-chat-width.test.ts @@ -0,0 +1,123 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + clampQuickChatWidth, + QUICK_CHAT_WIDTH_LIMITS, + useQuickChatWidth, +} from "./use-quick-chat-width"; + +const WIDTH_PROPERTY = "--quick-chat-width"; + +function mouseDown(clientX: number, dialog: HTMLElement) { + const handle = document.createElement("button"); + dialog.appendChild(handle); + return { + clientX, + currentTarget: handle, + preventDefault: () => {}, + } as unknown as React.MouseEvent; +} + +function mouseMove(clientX: number) { + document.dispatchEvent(new MouseEvent("mousemove", { clientX, bubbles: true })); +} + +function mouseUp() { + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); +} + +describe("quick chat dialog width", () => { + beforeEach(() => { + window.localStorage.clear(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1200 }); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }); + + it("defaults to 80 percent of the viewport", () => { + const { result } = renderHook(() => useQuickChatWidth()); + + expect(result.current.width).toBe(960); + }); + + it("restores and clamps a persisted width", () => { + window.localStorage.setItem(QUICK_CHAT_WIDTH_LIMITS.storageKey, JSON.stringify(5000)); + const { result } = renderHook(() => useQuickChatWidth()); + + expect(result.current.width).toBe(1200 - QUICK_CHAT_WIDTH_LIMITS.viewportMargin); + }); + + it("grows symmetrically when the right edge moves right", () => { + const { result } = renderHook(() => useQuickChatWidth()); + const dialog = document.createElement("div"); + + act(() => result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog))); + act(() => mouseMove(950)); + + expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("1060px"); + expect(result.current.width).toBe(960); + act(() => mouseUp()); + expect(result.current.width).toBe(1060); + }); + + it("grows symmetrically when the left edge moves left", () => { + const { result } = renderHook(() => useQuickChatWidth()); + const dialog = document.createElement("div"); + + act(() => result.current.leftResizeHandleProps.onMouseDown(mouseDown(300, dialog))); + act(() => mouseMove(250)); + + expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("1060px"); + expect(result.current.width).toBe(960); + act(() => mouseUp()); + expect(result.current.width).toBe(1060); + }); + + it("persists the final width on mouseup and restores it after remount", () => { + const first = renderHook(() => useQuickChatWidth()); + const dialog = document.createElement("div"); + act(() => first.result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog))); + act(() => mouseMove(950)); + act(() => mouseUp()); + first.unmount(); + + const second = renderHook(() => useQuickChatWidth()); + expect(second.result.current.width).toBe(1060); + }); + + it("clamps a drag on viewport resize without rendering until mouseup", () => { + const { result } = renderHook(() => useQuickChatWidth()); + const dialog = document.createElement("div"); + + act(() => result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog))); + act(() => mouseMove(950)); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 }); + act(() => window.dispatchEvent(new Event("resize"))); + + expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("968px"); + expect(result.current.width).toBe(960); + act(() => mouseUp()); + expect(result.current.width).toBe(968); + }); + + it("rebases an active drag after the viewport changes", () => { + const { result } = renderHook(() => useQuickChatWidth()); + const dialog = document.createElement("div"); + + act(() => result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog))); + act(() => mouseMove(950)); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 }); + act(() => window.dispatchEvent(new Event("resize"))); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1200 }); + act(() => window.dispatchEvent(new Event("resize"))); + act(() => mouseMove(951)); + + expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("970px"); + act(() => mouseUp()); + }); + + it("clamps to the minimum and current viewport", () => { + expect(clampQuickChatWidth(100, 1200)).toBe(QUICK_CHAT_WIDTH_LIMITS.minWidth); + expect(clampQuickChatWidth(5000, 900)).toBe(900 - QUICK_CHAT_WIDTH_LIMITS.viewportMargin); + }); +}); diff --git a/apps/web/hooks/use-quick-chat-width.ts b/apps/web/hooks/use-quick-chat-width.ts new file mode 100644 index 0000000000..b3098c15c5 --- /dev/null +++ b/apps/web/hooks/use-quick-chat-width.ts @@ -0,0 +1,135 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { getLocalStorage, setLocalStorage } from "@/lib/local-storage"; + +const STORAGE_KEY = "kandev.quickChat.dialogWidth"; +const DEFAULT_VIEWPORT_RATIO = 0.8; +const MIN_WIDTH = 480; +const VIEWPORT_MARGIN = 32; + +export const QUICK_CHAT_WIDTH_LIMITS = { + storageKey: STORAGE_KEY, + defaultViewportRatio: DEFAULT_VIEWPORT_RATIO, + minWidth: MIN_WIDTH, + viewportMargin: VIEWPORT_MARGIN, +} as const; + +function viewportWidth(): number { + return typeof window === "undefined" ? 1200 : window.innerWidth; +} + +export function clampQuickChatWidth(width: number, availableWidth = viewportWidth()): number { + const maximum = Math.max(0, availableWidth - VIEWPORT_MARGIN); + const minimum = Math.min(MIN_WIDTH, maximum); + return Math.round(Math.min(Math.max(width, minimum), maximum)); +} + +function readInitialWidth(): number { + const availableWidth = viewportWidth(); + const stored = getLocalStorage(STORAGE_KEY, null); + const preferred = + typeof stored === "number" && Number.isFinite(stored) + ? stored + : availableWidth * DEFAULT_VIEWPORT_RATIO; + return clampQuickChatWidth(preferred, availableWidth); +} + +function restoreBodyStyles() { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; +} + +type ResizeEdge = "left" | "right"; + +export function useQuickChatWidth() { + const [width, setWidth] = useState(readInitialWidth); + const widthRef = useRef(width); + const dragRef = useRef<{ + edge: ResizeEdge; + startX: number; + lastX: number; + startWidth: number; + dialog: HTMLElement; + } | null>(null); + + const updateWidth = useCallback((nextWidth: number) => { + widthRef.current = nextWidth; + setWidth(nextWidth); + }, []); + + const startResize = useCallback((edge: ResizeEdge, event: React.MouseEvent) => { + event.preventDefault(); + const dialog = event.currentTarget.parentElement; + if (!dialog) return; + dragRef.current = { + edge, + startX: event.clientX, + lastX: event.clientX, + startWidth: widthRef.current, + dialog, + }; + document.body.style.cursor = "ew-resize"; + document.body.style.userSelect = "none"; + }, []); + + const handleLeftMouseDown = useCallback( + (event: React.MouseEvent) => startResize("left", event), + [startResize], + ); + const handleRightMouseDown = useCallback( + (event: React.MouseEvent) => startResize("right", event), + [startResize], + ); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + const drag = dragRef.current; + if (!drag) return; + drag.lastX = event.clientX; + const pointerDelta = event.clientX - drag.startX; + const widthDelta = (drag.edge === "right" ? pointerDelta : -pointerDelta) * 2; + const nextWidth = clampQuickChatWidth(drag.startWidth + widthDelta); + widthRef.current = nextWidth; + drag.dialog.style.setProperty("--quick-chat-width", `${nextWidth}px`); + }; + const handleMouseUp = () => { + if (!dragRef.current) return; + dragRef.current = null; + restoreBodyStyles(); + setWidth(widthRef.current); + setLocalStorage(STORAGE_KEY, widthRef.current); + }; + const handleWindowResize = () => { + const nextWidth = clampQuickChatWidth(widthRef.current); + const drag = dragRef.current; + if (drag) { + widthRef.current = nextWidth; + drag.startWidth = nextWidth; + drag.startX = drag.lastX; + drag.dialog.style.setProperty("--quick-chat-width", `${nextWidth}px`); + return; + } + updateWidth(nextWidth); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + window.addEventListener("resize", handleWindowResize); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("resize", handleWindowResize); + if (dragRef.current) { + dragRef.current = null; + restoreBodyStyles(); + } + }; + }, [updateWidth]); + + return { + width, + leftResizeHandleProps: { onMouseDown: handleLeftMouseDown }, + rightResizeHandleProps: { onMouseDown: handleRightMouseDown }, + }; +} diff --git a/apps/web/lib/api/domains/github-api.test.ts b/apps/web/lib/api/domains/github-api.test.ts index f099f44f61..660888f3ae 100644 --- a/apps/web/lib/api/domains/github-api.test.ts +++ b/apps/web/lib/api/domains/github-api.test.ts @@ -9,6 +9,8 @@ import { createTaskPR, fetchAccessibleRepos, fetchIssueInfo, + fetchPRInfo, + fetchRepoBranches, getTaskCIAutomationOptions, GitHubUnavailableError, linkTaskIssue, @@ -46,6 +48,29 @@ function lastCallUrl(): string { return String(call[0]); } +describe("remote repository reads", () => { + it("retries a transient network failure when loading branches", async () => { + fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed")); + fetchSpy.mockResolvedValueOnce(jsonResponse({ branches: [{ name: "main" }] })); + + await expect(fetchRepoBranches("acme", "site")).resolves.toEqual({ + branches: [{ name: "main" }], + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("retries a transient network failure when loading PR title metadata", async () => { + fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed")); + fetchSpy.mockResolvedValueOnce(jsonResponse({ number: 42, title: "Recovered" })); + + await expect(fetchPRInfo("acme", "site", 42)).resolves.toMatchObject({ + number: 42, + title: "Recovered", + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); + describe("fetchAccessibleRepos — URL & parsing", () => { it("builds the correct URL with both q and limit", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] })); diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index 692a2213f1..80d32b1cf5 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -1,4 +1,4 @@ -import { fetchJson, ApiError, type ApiRequestOptions } from "../client"; +import { fetchJson, fetchJsonWithRetry, ApiError, type ApiRequestOptions } from "../client"; import type { GitHubStatusResponse, GitHubOrg, @@ -443,7 +443,7 @@ export async function fetchPRInfo( number: number, options?: ApiRequestOptions, ) { - return fetchJson( + return fetchJsonWithRetry( `/api/v1/github/prs/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info`, options, ); @@ -456,7 +456,7 @@ export async function fetchIssueInfo( number: number, options?: ApiRequestOptions, ) { - return fetchJson( + return fetchJsonWithRetry( `/api/v1/github/issues/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info`, options, ); @@ -464,7 +464,7 @@ export async function fetchIssueInfo( // Remote repo branches export async function fetchRepoBranches(owner: string, repo: string, options?: ApiRequestOptions) { - return fetchJson<{ branches: { name: string }[] }>( + return fetchJsonWithRetry<{ branches: { name: string }[] }>( `/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, options, ); diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts index 1e99d46f91..878027672f 100644 --- a/apps/web/lib/api/domains/workspace-api.ts +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -85,18 +85,39 @@ export async function listRepositoryScripts(repositoryId: string, options?: ApiR } // Quick Chat operations -export type StartQuickChatRequest = { +type StartQuickChatCommon = { title?: string; - repository_id?: string; agent_profile_id?: string; executor_id?: string; prompt?: string; +}; + +type StartQuickChatLegacyRepository = { + repositories?: never; + repository_id?: string; local_path?: string; repository_name?: string; default_branch?: string; base_branch?: string; }; +type StartQuickChatRepositories = { + repositories: QuickChatRepositoryInput[]; + repository_id?: never; + local_path?: never; + repository_name?: never; + default_branch?: never; + base_branch?: never; +}; + +export type StartQuickChatRequest = StartQuickChatCommon & + (StartQuickChatLegacyRepository | StartQuickChatRepositories); + +export type QuickChatRepositoryInput = { + repository_id: string; + base_branch: string; +}; + export type StartQuickChatResponse = { task_id: string; session_id: string; diff --git a/docs/decisions/0038-quick-chat-repository-isolation.md b/docs/decisions/0038-quick-chat-repository-isolation.md new file mode 100644 index 0000000000..f7acc13163 --- /dev/null +++ b/docs/decisions/0038-quick-chat-repository-isolation.md @@ -0,0 +1,42 @@ +# 0038: Quick Chat Repository Isolation + +**Status:** accepted +**Date:** 2026-07-14 +**Area:** backend, frontend + +## Context + +Quick chat already creates an ephemeral task and can associate one repository, but it resolves +the workspace default executor. When that executor is local, selecting another branch can +switch the branch in the user's source checkout. Adding multiple repositories amplifies that +risk, while cloning every repository into a new quick-chat-specific directory would duplicate +the existing task materialization and cleanup systems. + +## Decision + +Repo-backed quick chats use the built-in worktree executor and the existing multi-repository +task preparation pipeline. The quick-chat request accepts an ordered repository list and the +backend enforces the worktree executor whenever that list is non-empty. Repo-less chats retain +the current scratch workspace and resolved default executor. + +The worktrees remain owned by the ephemeral task and use the existing task environment, +session worktree, rollback, deletion, expiration, and garbage-collection paths. The selected +base branch supplies committed repository state; uncommitted source-checkout changes are not +copied. + +## Consequences + +Concurrent chats can target the same repository and base branch without mutating the user's +checkout. Materialization stays smaller and faster than a full clone, and no second cleanup +system is introduced. A chat does not see uncommitted local changes, and repository-backed +quick chats always run through the local worktree executor rather than a workspace default +remote executor in this initial version. + +## Alternatives Considered + +- **Clone every repository under `quick-chat/`.** Rejected because it duplicates clone, + credential, preparation, persistence, and cleanup behavior already owned by task worktrees. +- **Use the workspace default executor unchanged.** Rejected because a local executor may + checkout the requested branch in the user's source directory. +- **Copy the current working directory.** Rejected because it silently includes uncommitted or + ignored files and has unclear performance and privacy behavior. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index a268a89a04..b7ff1853ec 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -43,3 +43,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0035 | [Version AgentReady events by prompt generation](0035-version-agent-ready-events-by-prompt-generation.md) | accepted | backend | 2026-07-14 | | 0036 | [Normalize ACP shell output at the adapter boundary](0036-normalize-acp-shell-output-at-adapter-boundary.md) | accepted | backend, frontend, protocol | 2026-07-14 | | 0037 | [Resource-aware frontend unit tests](0037-resource-aware-frontend-unit-tests.md) | accepted | frontend, infra | 2026-07-14 | +| 0038 | [Quick Chat Repository Isolation](0038-quick-chat-repository-isolation.md) | accepted | backend, frontend | 2026-07-14 | diff --git a/docs/plans/quick-chat-repository-context/plan.md b/docs/plans/quick-chat-repository-context/plan.md new file mode 100644 index 0000000000..233bd169c5 --- /dev/null +++ b/docs/plans/quick-chat-repository-context/plan.md @@ -0,0 +1,55 @@ +--- +spec: docs/specs/tasks/quick-chat-repository-context.md +created: 2026-07-14 +status: complete +--- + +# Implementation Plan: Quick Chat Repository Context + +## Overview + +Extend quick chat's existing ephemeral-task contract to carry ordered repository inputs and +enforce isolated worktree preparation. Then extract the controlled workspace repo/branch chips +from task creation, replace the agent-card picker with a responsive setup form, and verify the +desktop and mobile flows. + +## Backend + +- Extend `httpStartQuickChatRequest` in + `apps/backend/internal/task/handlers/task_http_handlers.go` with plural repository inputs. +- Normalize plural or legacy singleton fields into `[]service.TaskRepositoryInput`, reject mixed + shapes, and force `models.ExecutorIDWorktree` for repo-backed starts. +- Preserve eager launch and rollback through `Service.DeleteTask`. + +## Frontend + +- Add the plural request type in `apps/web/lib/api/domains/workspace-api.ts`. +- Extract a controlled workspace repository/branch chip component from + `apps/web/components/task-create-dialog-repo-chips.tsx` without moving task-only URL/folder and + fresh-branch modes. +- Add `apps/web/components/quick-chat/quick-chat-setup.tsx`; use the shared agent selector, + repository chips, approved helper copy, and responsive footer. +- Keep start supersession and orphan cleanup in `use-quick-chat-modal.ts`. + +## Tests + +- Backend handler tests cover plural normalization, compatibility, isolation, and validation. +- Frontend unit tests cover payload construction/default selection logic and supersession. +- Existing task chip tests guard the extraction against regressions. + +## E2E Tests + +- Extend `apps/web/e2e/tests/chat/quick-chat.spec.ts` for setup helper copy and repo-backed start. +- Add a `mobile-*.spec.ts` flow for the same user value and narrow layout. + +## Implementation Waves + +Wave 1: +- [x] [task-01-backend-contract](task-01-backend-contract.md) +- [x] [task-02-shared-repository-chips](task-02-shared-repository-chips.md) + +Wave 2: +- [x] [task-03-quick-chat-setup](task-03-quick-chat-setup.md) + +Wave 3: +- [x] [task-04-e2e-verification](task-04-e2e-verification.md) diff --git a/docs/plans/quick-chat-repository-context/task-01-backend-contract.md b/docs/plans/quick-chat-repository-context/task-01-backend-contract.md new file mode 100644 index 0000000000..d7af371d16 --- /dev/null +++ b/docs/plans/quick-chat-repository-context/task-01-backend-contract.md @@ -0,0 +1,23 @@ +--- +id: "01-backend-contract" +title: "Backend quick-chat repository contract" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-repository-context.md" +--- + +# Task 01: Backend quick-chat repository contract + +**Acceptance:** plural repository inputs preserve order and branches; legacy singleton input +remains accepted; repo-backed starts always launch with the worktree executor. + +**Verification:** `make -C apps/backend test` from the repository root. + +**Files likely touched:** `apps/backend/internal/task/handlers/task_http_handlers.go`, +`apps/backend/internal/task/handlers/task_http_handlers_test.go`. + +**Dependencies:** None. + +**Output contract:** summarize contract changes, tests run, files changed, risks, and status. diff --git a/docs/plans/quick-chat-repository-context/task-02-shared-repository-chips.md b/docs/plans/quick-chat-repository-context/task-02-shared-repository-chips.md new file mode 100644 index 0000000000..bd3db1c9cb --- /dev/null +++ b/docs/plans/quick-chat-repository-context/task-02-shared-repository-chips.md @@ -0,0 +1,23 @@ +--- +id: "02-shared-repository-chips" +title: "Shared controlled repository chips" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-repository-context.md" +--- + +# Task 02: Shared controlled repository chips + +**Acceptance:** task creation and quick chat can use one controlled workspace repo/branch chip +surface; task-only source modes remain unchanged; quick chat can exclude duplicate repos. + +**Verification:** run the focused repository-chip Vitest files from `apps/`. + +**Files likely touched:** `apps/web/components/task-create-dialog-repo-chips.tsx`, new shared +component and tests. + +**Dependencies:** None. + +**Output contract:** summarize extraction, tests run, files changed, risks, and status. diff --git a/docs/plans/quick-chat-repository-context/task-03-quick-chat-setup.md b/docs/plans/quick-chat-repository-context/task-03-quick-chat-setup.md new file mode 100644 index 0000000000..ff9e8eb6e3 --- /dev/null +++ b/docs/plans/quick-chat-repository-context/task-03-quick-chat-setup.md @@ -0,0 +1,22 @@ +--- +id: "03-quick-chat-setup" +title: "Responsive quick-chat setup" +status: done +wave: 2 +depends_on: ["01-backend-contract", "02-shared-repository-chips"] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-repository-context.md" +--- + +# Task 03: Responsive quick-chat setup + +**Acceptance:** users select an agent and optional repository branches before starting; first-use +and field helper copy render correctly; failed/superseded starts preserve current cleanup rules. + +**Verification:** run focused quick-chat Vitest tests and web typecheck from `apps/web`. + +**Files likely touched:** quick-chat modal/hook/setup files and workspace API types. + +**Dependencies:** Tasks 01 and 02. + +**Output contract:** summarize UI/state changes, tests run, files changed, risks, and status. diff --git a/docs/plans/quick-chat-repository-context/task-04-e2e-verification.md b/docs/plans/quick-chat-repository-context/task-04-e2e-verification.md new file mode 100644 index 0000000000..56e6beb0dc --- /dev/null +++ b/docs/plans/quick-chat-repository-context/task-04-e2e-verification.md @@ -0,0 +1,27 @@ +--- +id: "04-e2e-verification" +title: "Quick-chat repository E2E verification" +status: done +wave: 3 +depends_on: ["03-quick-chat-setup"] +plan: "plan.md" +spec: "../../specs/tasks/quick-chat-repository-context.md" +--- + +# Task 04: Quick-chat repository E2E verification + +**Acceptance:** desktop and mobile flows start a repo-backed quick chat; source checkout remains +unchanged; controls fit narrow viewports. + +**Verification:** from `apps/web`, run +`pnpm e2e:run --host --no-build --project chromium -- tests/chat/quick-chat.spec.ts` +(6 passed) and +`pnpm e2e:run --host --no-build --project mobile-chrome -- tests/chat/mobile-quick-chat-repository.spec.ts` +(1 passed). From the repository root, run `make fmt`, `make typecheck`, `make test`, and +`make lint`; all completed successfully. + +**Files likely touched:** quick-chat desktop/mobile E2E specs. + +**Dependencies:** Task 03. + +**Output contract:** report scenarios, commands, results, artifacts, risks, and status. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 10fc99c586..9585cf089f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -51,6 +51,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [wip-limit-pull-system](tasks/wip-limit-pull-system.md) | building | | [multi-branch](tasks/multi-branch/spec.md) | shipped | | [quick-chat-expiration](tasks/quick-chat-expiration.md) | draft | +| [quick-chat-repository-context](tasks/quick-chat-repository-context.md) | shipped | | [parent-child-message-interrupt](tasks/parent-child-message-interrupt.md) | shipped | ## agents/ — agent governance diff --git a/docs/specs/tasks/quick-chat-repository-context.md b/docs/specs/tasks/quick-chat-repository-context.md new file mode 100644 index 0000000000..91c3a74b55 --- /dev/null +++ b/docs/specs/tasks/quick-chat-repository-context.md @@ -0,0 +1,115 @@ +--- +status: shipped +created: 2026-07-14 +owner: kandev +--- + +# Quick Chat Repository Context + +## Why + +Quick chat is useful for focused questions, but its agent-only picker does not explain the +surface or let a user ground the conversation in repository code. Users need optional code +context without changing their checked-out branches or creating a kanban task. + +## What + +- A new quick chat presents a searchable agent-profile selector and requires an agent before + it can start. +- A user may add zero, one, or multiple imported workspace repositories. Each selected + repository has one explicit base branch and can appear at most once in a chat. +- Selecting a repository automatically fills its preferred branch (`main`, `master`, or the + first available branch), including when another repository lookup is still pending. +- A workspace with no existing quick chats explains that quick chat is for discussing an idea, + question, or codebase outside the task board. +- Agent and repository fields include concise helper copy. Repository copy explains that the + optional selection focuses the conversation on specific code and branches. +- Repo-backed quick chats run in Kandev-owned isolated worktrees. They never switch or modify + the branch checked out in the user's source directory. +- Repository context contains committed branch state. Uncommitted files from the user's + working directory are not copied into the chat. +- Repo-less quick chats retain their current scratch workspace and default-executor behavior. +- The desktop quick-chat dialog stays horizontally centered and can be resized from either + horizontal edge. Its last user-selected width is restored across browser sessions and clamped + to the current viewport. +- The quick-chat message history and composer retain the dialog's popover surface color. The setup + footer uses that same continuous surface, and the new chat action appears immediately after the + last chat tab. +- Quick-chat tabs remain in creation order across reloads; later activity does not reorder them. +- Hovering either resize target highlights the dialog's outer border, not an inset line. +- The setup flow supports desktop and mobile layouts without horizontal page scrolling or + hover-only actions. Mobile quick chat remains full-screen and does not expose resize handles. + +Decision: [ADR 0038](../../decisions/0038-quick-chat-repository-isolation.md). + +## API surface + +`POST /api/v1/workspaces/:id/quick-chat` accepts: + +```json +{ + "title": "Codex - Chat 1", + "agent_profile_id": "profile-id", + "repositories": [ + { "repository_id": "repo-1", "base_branch": "main" }, + { "repository_id": "repo-2", "base_branch": "develop" } + ] +} +``` + +`agent_profile_id` is required after workspace-default resolution. `repositories` is optional +and ordered. The legacy singleton repository fields remain accepted, but callers cannot mix +the singleton and plural shapes in one request. + +The response remains: + +```json +{ "task_id": "task-id", "session_id": "session-id" } +``` + +## Failure modes + +- An incomplete repository row keeps Start Chat disabled. +- A missing agent, foreign repository, duplicate repository, invalid branch, or mixed request + shape fails validation and does not launch an agent. +- If repository preparation or agent launch fails, the backend deletes the ephemeral task and + its materialized worktrees. The setup remains visible with its selections and reports the + error. +- A superseded in-flight start still deletes the completed orphan task. + +## Persistence guarantees + +Quick-chat task/session rows and their isolated worktrees survive backend restarts under the +existing task runtime rules. Closing, deleting, workspace deletion, or idle expiration cleans +them through the existing quick-chat task deletion path. + +## Scenarios + +- **GIVEN** a workspace with no quick chats, **WHEN** the user opens Quick Chat, **THEN** the + setup explains its purpose and shows agent and repository helper copy. +- **GIVEN** an agent and no repositories, **WHEN** the user starts a chat, **THEN** the chat uses + the existing repo-less scratch workspace. +- **GIVEN** an agent and two repository/branch selections, **WHEN** the user starts a chat, + **THEN** the agent workspace contains both repositories at committed state from those branches. +- **GIVEN** a repository with an empty branch selection, **WHEN** its branch list loads, **THEN** + the preferred branch is selected automatically even if a prior repository request is pending. +- **GIVEN** the user's source checkout is on another branch or dirty, **WHEN** a repo-backed chat + starts, **THEN** the source checkout and its uncommitted files remain unchanged. +- **GIVEN** one repo-backed chat is active, **WHEN** another chat targets the same repository and + base branch, **THEN** both start in distinct Kandev-owned worktrees. +- **GIVEN** an existing quick chat, **WHEN** the user opens a new chat tab, **THEN** the long + first-use introduction is omitted while field helper copy remains. +- **GIVEN** a mobile viewport, **WHEN** the user selects an agent and repository branch, **THEN** + all controls remain touch-accessible and the page has no horizontal overflow. +- **GIVEN** a desktop quick-chat dialog, **WHEN** the user drags either horizontal edge, **THEN** + the centered dialog resizes within viewport limits and restores that width the next time it + opens. +- **GIVEN** one or more quick-chat tabs, **WHEN** the tab strip renders, **THEN** the new-chat + action sits directly after the final tab rather than at the far edge of the dialog. + +## Out of scope + +- Remote repository URL entry or arbitrary local folders. +- Including uncommitted working-directory changes. +- Selecting the same repository more than once in a single quick chat. +- Choosing an executor in the quick-chat setup.