feat: add repository context to quick chats (#1679)

This commit is contained in:
Carlos Florêncio
2026-07-15 09:13:52 +01:00
committed by GitHub
parent 55c8b461be
commit 1a1ece3a8d
39 changed files with 2204 additions and 643 deletions
+5 -22
View File
@@ -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,
@@ -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" {
@@ -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()
@@ -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
@@ -84,7 +84,7 @@ export const QuickChatContent = memo(function QuickChatContent({
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden bg-popover" data-testid="quick-chat-messages">
<MessageList
items={panelState.groupedItems}
messages={panelState.allMessages}
@@ -129,6 +129,7 @@ export const QuickChatContent = memo(function QuickChatContent({
minimalToolbar={minimalToolbar}
hidePlanMode={true}
placeholderOverride={placeholderOverride}
surfaceClassName="bg-popover"
/>
</div>
);
@@ -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({
/>
);
})}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0"
onClick={onNewChat}
aria-label="Start new chat"
>
<IconPlus className="h-3.5 w-3.5" />
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0"
onClick={onNewChat}
aria-label="Start new chat"
>
<IconPlus className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function QuickChatResizeHandle({
edge,
onMouseDown,
}: {
edge: "left" | "right";
onMouseDown: (event: React.MouseEvent) => void;
}) {
return (
<button
type="button"
tabIndex={-1}
aria-label={`Resize quick chat from ${edge}`}
data-testid={`quick-chat-resize-${edge}`}
onMouseDown={onMouseDown}
className={`group absolute inset-y-0 z-20 hidden w-2 cursor-ew-resize items-center justify-center sm:flex ${
edge === "left" ? "left-0" : "right-0"
}`}
>
<span
className={`absolute inset-y-0 w-px bg-transparent transition-colors group-hover:bg-primary/60 ${
edge === "left" ? "-left-px" : "-right-px"
}`}
/>
</button>
);
}
function useIsQuickChatPassthrough(sessionId: string) {
return useAppStore(
useShallow((s) => {
@@ -91,65 +120,13 @@ function QuickChatSessionView({ sessionId }: { sessionId: string }) {
return <QuickChatContent sessionId={sessionId} />;
}
function AgentPickerView({
onSelectAgent,
pendingAgentId,
}: {
onSelectAgent: (agentId: string) => void;
pendingAgentId: string | null;
}) {
const agentProfiles = useAppStore((s) => s.agentProfiles.items) ?? [];
const isLoading = pendingAgentId !== null;
return (
<div className="flex-1 flex flex-col items-center justify-center p-8">
<div className="max-w-2xl w-full space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Choose an agent to start chatting</h3>
<p className="text-sm text-muted-foreground">
Select an AI agent to begin your conversation
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{agentProfiles.map((profile) => {
const isPending = pendingAgentId === profile.id;
return (
<button
key={profile.id}
onClick={() => onSelectAgent(profile.id)}
disabled={isLoading}
className="group relative flex flex-col items-start gap-2 rounded-lg border p-4 text-left transition-all hover:border-primary hover:bg-accent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-transparent"
>
<div className="flex items-center gap-2 w-full">
<div className="flex h-8 w-8 items-center justify-center rounded-md border bg-background">
{isPending ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconMessageCircle className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{profile.label}</p>
<p className="text-xs text-muted-foreground truncate">
{isPending ? "Starting agent..." : profile.agent_name}
</p>
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
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 (
<>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className="!max-w-[80vw] !w-[80vw] max-h-[85vh] h-[85vh] p-0 gap-0 flex flex-col shadow-2xl"
className="!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 p-0 shadow-2xl sm:!left-1/2 sm:!top-1/2 sm:!h-[85vh] sm:!max-h-[85vh] sm:!w-[var(--quick-chat-width)] sm:!max-w-[calc(100vw-2rem)] sm:!-translate-x-1/2 sm:!-translate-y-1/2"
style={{ "--quick-chat-width": `${width}px` } as CSSProperties}
showCloseButton={false}
overlayClassName="bg-transparent"
>
<DialogTitle className="sr-only">Quick Chat</DialogTitle>
<QuickChatResizeHandle edge="left" {...leftResizeHandleProps} />
<QuickChatResizeHandle edge="right" {...rightResizeHandleProps} />
<QuickChatTabs
sessions={sessions}
activeSessionId={activeSessionId || ""}
@@ -183,7 +165,14 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
<QuickChatSessionView sessionId={activeSessionId} />
)}
{activeSessionNeedsAgent && (
<AgentPickerView onSelectAgent={handleSelectAgent} pendingAgentId={pendingAgentId} />
<QuickChatSetup
key={`${workspaceId}:${setupKey}`}
workspaceId={workspaceId}
showIntroduction={!hasCreatedChat}
pendingAgentId={pendingAgentId}
onStart={handleSelectAgent}
onCancel={() => handleOpenChange(false)}
/>
)}
</DialogContent>
</Dialog>
@@ -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;
}) => (
<button
type="button"
data-testid={AGENT_SELECTOR_TEST_ID}
className={triggerClassName}
onClick={() => onValueChange("agent-b")}
>
{value || "Select agent"}
</button>
),
}));
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(<QuickChatSetup {...props} />);
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(<QuickChatSetup {...props} />);
expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("Select agent");
defaultAgentId = "agent-a";
rerender(<QuickChatSetup {...props} />);
expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("agent-a");
});
it("preserves an explicit selection when the workspace default changes", () => {
const { rerender } = render(<QuickChatSetup {...props} />);
fireEvent.click(screen.getByTestId(AGENT_SELECTOR_TEST_ID));
expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("agent-b");
defaultAgentId = "agent-a";
rerender(<QuickChatSetup {...props} />);
expect(screen.getByTestId(AGENT_SELECTOR_TEST_ID).textContent).toContain("agent-b");
});
});
@@ -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 (
<div className="space-y-1" data-testid="quick-chat-introduction">
<p className="text-sm text-foreground">
Chat with an agent about an idea, question, or codebase.
</p>
<p className="text-sm text-muted-foreground">Quick chats stay outside your task board.</p>
</div>
);
}
function AgentField({
profiles,
value,
disabled,
onChange,
}: {
profiles: AgentProfileOption[];
value: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const options = useAgentProfileOptions(profiles);
return (
<section className="space-y-2" aria-labelledby="quick-chat-agent-label">
<div>
<h3 id="quick-chat-agent-label" className="text-sm font-medium">
Agent profile
</h3>
<p id="quick-chat-agent-help" className="text-xs text-muted-foreground">
Choose the agent for this conversation.
</p>
</div>
<AgentSelector
options={options}
value={value}
onValueChange={onChange}
disabled={disabled}
placeholder={profiles.length > 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
/>
</section>
);
}
type RepositoryFieldProps = {
workspaceId: string;
repositories: Repository[];
rows: ReturnType<typeof useRepositoriesState>["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 (
<section className="space-y-3" aria-labelledby="quick-chat-repositories-label">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<h3 id="quick-chat-repositories-label" className="text-sm font-medium">
Repositories <span className="font-normal text-muted-foreground">(optional)</span>
</h3>
<p id="quick-chat-repositories-help" className="text-xs text-muted-foreground">
Add repository context to focus on specific code and branches.
</p>
</div>
<RepositoryContextHelp />
</div>
<div
className="flex min-h-11 flex-wrap items-center gap-2"
aria-describedby="quick-chat-repositories-help"
>
<WorkspaceRepoChips
rows={props.rows}
repositories={props.repositories}
workspaceId={props.workspaceId}
canAddMore={props.canAddMore}
addHint={props.addHint}
addLabel="Add repository"
allowDuplicateRepositories={false}
onAdd={props.onAdd}
onRemove={props.onRemove}
onRowRepositoryChange={props.onRepositoryChange}
onRowBranchChange={props.onBranchChange}
/>
</div>
</section>
);
}
function RepositoryContextHelp() {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="About repository context"
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconInfoCircle className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Kandev uses an isolated worktree from the selected branch. Uncommitted local changes are not
included.
</TooltipContent>
</Tooltip>
);
}
function SetupFooter({
isStarting,
startDisabled,
onCancel,
onStart,
}: {
isStarting: boolean;
startDisabled: boolean;
onCancel: () => void;
onStart: () => void;
}) {
return (
<footer
className="flex shrink-0 items-center justify-end gap-2 border-t bg-popover px-4 py-3 sm:px-8"
data-testid="quick-chat-setup-footer"
>
<Button variant="outline" onClick={onCancel} disabled={isStarting} className="cursor-pointer">
Cancel
</Button>
<Button
onClick={onStart}
disabled={startDisabled}
className="min-w-28 cursor-pointer"
data-testid="quick-chat-start"
data-dialog-default-action
>
{isStarting ? <IconLoader2 className="h-4 w-4 animate-spin" /> : null}
{isStarting ? "Starting chat..." : "Start chat"}
</Button>
</footer>
);
}
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<QuickChatRepositoryInput[]>(
() =>
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 (
<div className="flex min-h-0 flex-1 flex-col bg-popover" data-testid="quick-chat-setup">
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-8 sm:py-8">
<div className="mx-auto w-full max-w-2xl space-y-7">
<header className="space-y-1">
<h2 className="text-lg font-semibold">Quick Chat</h2>
<QuickChatIntroduction show={showIntroduction} />
</header>
<AgentField
profiles={agentProfiles}
value={agentProfileId}
disabled={isStarting}
onChange={setAgentProfileId}
/>
<RepositoryField
workspaceId={workspaceId}
repositories={repositories}
rows={repoState.repositories}
canAddMore={canAddMore}
addHint={addHint}
onAdd={repoState.addRepository}
onRemove={repoState.removeRepository}
onRepositoryChange={handleRepositoryChange}
onBranchChange={handleBranchChange}
/>
</div>
</div>
<SetupFooter
isStarting={isStarting}
startDisabled={startDisabled}
onCancel={onCancel}
onStart={() => onStart(agentProfileId, selectedRepositories)}
/>
</div>
);
}
@@ -62,6 +62,7 @@ export const QuickChatTabItem = memo(function QuickChatTabItem({
return (
<div
data-testid="quick-chat-tab"
className={`flex items-center gap-1 rounded transition-colors whitespace-nowrap ${
isActive
? "bg-background text-foreground shadow-sm"
@@ -5,6 +5,12 @@ import { renderHook, act } from "@testing-library/react";
const mockToast = vi.fn();
const mockStartQuickChat = vi.fn();
const mockDeleteTask = vi.fn();
let mockAppState: ReturnType<typeof makeAppState>;
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: ReturnType<typeof makeAppState>) => 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<typeof useAgentSelection>[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> = {}): 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(
@@ -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<typeof useQuickChatStore>;
/** 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<string | null>(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,
@@ -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(
<WorkspaceRepoChips
rows={rows}
repositories={repositories}
workspaceId="ws-1"
canAddMore
allowDuplicateRepositories={allowDuplicateRepositories}
onAdd={vi.fn()}
onRemove={vi.fn()}
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
/>,
);
}
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();
});
});
@@ -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 (
<ChipsList
fs={fs}
<WorkspaceRepoChips
rows={fs.repositories}
repositories={repositories}
discoveredRepositories={fs.discoveredRepositories}
workspaceId={workspaceId}
branchLocked={branchLocked}
isLocalExecutor={isLocalExecutor}
currentLocalBranch={fs.currentLocalBranch}
currentLocalBranchLoading={fs.currentLocalBranchLoading}
freshBranchEnabled={fs.freshBranchEnabled}
canAddMore={canAddMore}
addHint={addHint}
onAdd={fs.addRepository}
onRemove={fs.removeRepository}
onRowRepositoryChange={onRowRepositoryChange}
onRowBranchChange={onRowBranchChange}
lastUsedBranch={lastUsedBranch}
@@ -218,99 +207,6 @@ function ModeBody({
);
}
/**
* 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.
*/
function ChipsList({
fs,
repositories,
workspaceId,
branchLocked,
isLocalExecutor,
canAddMore,
addHint,
freshBranchToggle,
onRowRepositoryChange,
onRowBranchChange,
lastUsedBranch,
userSettingsLoaded,
}: {
fs: DialogFormState;
repositories: Repository[];
workspaceId: string | null;
branchLocked: boolean;
isLocalExecutor: boolean;
canAddMore: boolean;
addHint?: string;
freshBranchToggle?: React.ReactNode;
onRowRepositoryChange: (key: string, value: string) => void;
onRowBranchChange: (key: string, value: string) => void;
lastUsedBranch?: string | null;
userSettingsLoaded?: boolean;
}) {
return (
<>
{fs.repositories.map((row) => (
<RepoChip
key={row.key}
row={row}
workspaceId={workspaceId}
repositories={repositories}
discoveredRepositories={fs.discoveredRepositories}
// Multi-branch: the same repository may be reused across rows when
// each row picks a different branch. Only exclude rows that hold
// the exact (repo, branch) pair this row would clash with on the
// backend — empty-branch rows can't collide yet, so they pass.
excludedRepoIds={collectExactDuplicateRepoIds(fs.repositories, row)}
branchLocked={branchLocked}
// For local-executor rows, seed row.branch with the workspace's
// current branch via this prop. Non-local rows leave it undefined
// and fall back to the existing last-used / preferred-default
// autoselect path.
preferredDefaultBranch={isLocalExecutor ? fs.currentLocalBranch : undefined}
preferredDefaultBranchLoading={isLocalExecutor ? fs.currentLocalBranchLoading : false}
lastUsedBranch={lastUsedBranch}
userSettingsLoaded={userSettingsLoaded}
branchPrefix={computeBranchPrefix({
isLocalExecutor,
rowBranch: row.branch,
currentLocalBranch: fs.currentLocalBranch,
freshBranchEnabled: !!fs.freshBranchEnabled,
})}
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
onBranchChange={(value) => onRowBranchChange(row.key, value)}
onRemove={() => fs.removeRepository(row.key)}
/>
))}
{freshBranchToggle}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
onClick={fs.addRepository}
disabled={!canAddMore}
aria-label="Add repository"
data-testid="add-repository"
className={cn(
"h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground",
canAddMore
? "hover:bg-muted hover:text-foreground cursor-pointer"
: "opacity-40 cursor-not-allowed",
)}
>
<IconPlus className="h-3.5 w-3.5" />
</button>
</span>
</TooltipTrigger>
<TooltipContent>{addHint ?? "Add another repository"}</TooltipContent>
</Tooltip>
</>
);
}
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<string> {
const ids = new Set<string>();
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<string>;
/**
* 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<BranchSource | null>(() => {
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 (
<span
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
data-testid="repo-chip"
data-repository-id={row.repositoryId || row.localPath || ""}
>
<Pill
icon={<IconCode className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={repoLabel}
placeholder="repository"
options={repoOptions}
onSelect={onRepositoryChange}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
testId="repo-chip-trigger"
tooltip={repoTooltip}
filter={scoreRepo}
flat
/>
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
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
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRemove}
aria-label="Remove repository"
className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer"
data-testid="remove-repo-chip"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove repository</TooltipContent>
</Tooltip>
</span>
);
}
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 (
<span className="flex min-w-0 flex-1 flex-col overflow-hidden" title={display || repo.name}>
<span className="truncate">{repo.name}</span>
{display ? (
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
) : null}
</span>
);
}
function renderDiscoveredRepoOption(path: string) {
const display = formatUserHomePath(path);
return (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" title={display}>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{leafSegment(path)}</span>
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
</span>
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
on disk
</Badge>
</span>
);
}
function leafSegment(path: string): string {
const cleaned = path.replace(/\\/g, "/").replace(/\/+$/g, "");
const idx = cleaned.lastIndexOf("/");
return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
}
@@ -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");
});
});
+16 -13
View File
@@ -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<typeof usePRInfoByURL>;
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
@@ -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) => (
<RepoChip
key={row.key}
row={row}
workspaceId={workspaceId}
repositories={repositories}
discoveredRepositories={discoveredRepositories ?? []}
// Task creation allows the same repository on different branches;
// quick chat excludes a repository as soon as another row uses it.
excludedRepoIds={collectExcludedRepoIds(rows, row, allowDuplicateRepositories)}
branchLocked={branchLocked}
// For local-executor rows, seed row.branch with the workspace's
// current branch via this prop. Non-local rows leave it undefined
// and fall back to the existing last-used / preferred-default
// autoselect path.
preferredDefaultBranch={isLocalExecutor ? currentLocalBranch : undefined}
preferredDefaultBranchLoading={isLocalExecutor ? currentLocalBranchLoading : false}
lastUsedBranch={lastUsedBranch}
userSettingsLoaded={userSettingsLoaded}
branchPrefix={computeBranchPrefix({
isLocalExecutor: !!isLocalExecutor,
rowBranch: row.branch,
currentLocalBranch: currentLocalBranch ?? "",
freshBranchEnabled: !!freshBranchEnabled,
})}
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
onBranchChange={(value) => onRowBranchChange(row.key, value)}
onRemove={() => onRemove(row.key)}
/>
))}
{freshBranchToggle}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={canAddMore ? undefined : 0}>
<button
type="button"
onClick={onAdd}
disabled={!canAddMore}
aria-label="Add repository"
data-testid="add-repository"
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-md text-muted-foreground",
addLabel ? "h-9 px-2 text-xs" : "h-7 w-7",
canAddMore
? "hover:bg-muted hover:text-foreground cursor-pointer"
: "opacity-40 cursor-not-allowed",
)}
>
<IconPlus className="h-3.5 w-3.5" />
{addLabel ? <span>{addLabel}</span> : null}
</button>
</span>
</TooltipTrigger>
<TooltipContent>{addHint ?? "Add another repository"}</TooltipContent>
</Tooltip>
</>
);
}
/**
* 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<string> {
const ids = new Set<string>();
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<string>;
/**
* 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<BranchSource | null>(() => {
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 (
<span
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
data-testid="repo-chip"
data-repository-id={row.repositoryId || row.localPath || ""}
>
<Pill
icon={<IconCode className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={repoLabel}
placeholder="repository"
options={repoOptions}
onSelect={onRepositoryChange}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
testId="repo-chip-trigger"
tooltip={repoTooltip}
filter={scoreRepo}
flat
/>
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
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
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRemove}
aria-label="Remove repository"
className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer"
data-testid="remove-repo-chip"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove repository</TooltipContent>
</Tooltip>
</span>
);
}
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 (
<span className="flex min-w-0 flex-1 flex-col overflow-hidden" title={display || repo.name}>
<span className="truncate">{repo.name}</span>
{display ? (
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
) : null}
</span>
);
}
function renderDiscoveredRepoOption(path: string) {
const display = formatUserHomePath(path);
return (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" title={display}>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{leafSegment(path)}</span>
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
</span>
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
on disk
</Badge>
</span>
);
}
function leafSegment(path: string): string {
const cleaned = path.replace(/\\/g, "/").replace(/\/+$/g, "");
const idx = cleaned.lastIndexOf("/");
return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
}
@@ -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 (
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
<div
data-testid="chat-input-area"
className={cn("bg-card flex-shrink-0 px-2 pb-2 pt-1", surfaceClassName)}
>
<QueueAffordance
sessionId={resolvedSessionId}
canDrain={canDrainQueue}
+14 -9
View File
@@ -31,18 +31,23 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
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
@@ -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);
});
});
+193 -26
View File
@@ -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<Locator> {
async function openQuickChatSetup(page: Page): Promise<Locator> {
await page.goto("/");
await page.waitForLoadState("networkidle");
@@ -18,20 +20,31 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
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<Locator> {
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<Locator> {
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<HTMLElement>('[data-testid="quick-chat-setup"]');
const footer = element.querySelector<HTMLElement>('[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<HTMLElement>('[data-testid="quick-chat-messages"]');
const input = element.querySelector<HTMLElement>('[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")');
@@ -65,18 +65,23 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
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 });
@@ -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");
@@ -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
@@ -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<string, unknown[]>,
loadedByRepositoryId: {} as Record<string, boolean>,
loadingByRepositoryId: {} as Record<string, boolean>,
},
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",
}),
);
});
});
@@ -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<Set<string>>(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
+123
View File
@@ -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);
});
});
+135
View File
@@ -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<number | null>(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 },
};
}
@@ -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: [] }));
+4 -4
View File
@@ -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<GitHubPR>(
return fetchJsonWithRetry<GitHubPR>(
`/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<GitHubIssue>(
return fetchJsonWithRetry<GitHubIssue>(
`/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,
);
+23 -2
View File
@@ -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;
@@ -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.
+1
View File
@@ -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 |
@@ -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)
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+1
View File
@@ -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
@@ -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.