feat: add repository context to quick chats (#1679)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [] }));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user