| author | |
| committer | |
| log | e2316814da7463b5332054f463f71020f1c50c08 |
| tree | b3d64c3404c56470c28a0563807978376ba7d0d2 |
| parent | 678f827f0b0713ab5ea9512ce12e034c2c923be8 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 181 insertions(+), 9 deletions(-)
apps/web/src/components/BranchToolbar.tsx+3-1| ... | ... | @@ -44,7 +44,9 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 44 | 44 | ); |
| 45 | 45 | const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); |
| 46 | 46 | const serverThread = useStore(serverThreadSelector); |
| 47 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | |
| 47 | const draftThread = useComposerDraftStore((store) => | |
| 48 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), | |
| 49 | ); | |
| 48 | 50 | const activeProjectRef = serverThread |
| 49 | 51 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 50 | 52 | : draftThread |
apps/web/src/components/BranchToolbarBranchSelector.tsx+3-1| ... | ... | @@ -91,7 +91,9 @@ export function BranchToolbarBranchSelector({ |
| 91 | 91 | const serverThread = useStore(serverThreadSelector); |
| 92 | 92 | const serverSession = serverThread?.session ?? null; |
| 93 | 93 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 94 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | |
| 94 | const draftThread = useComposerDraftStore((store) => | |
| 95 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), | |
| 96 | ); | |
| 95 | 97 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 96 | 98 | |
| 97 | 99 | const activeProjectRef = serverThread |
apps/web/src/components/ChatView.browser.tsx+115-1| ... | ... | @@ -1327,6 +1327,7 @@ async function mountChatView(options: { |
| 1327 | 1327 | snapshot: OrchestrationReadModel; |
| 1328 | 1328 | configureFixture?: (fixture: TestFixture) => void; |
| 1329 | 1329 | resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; |
| 1330 | initialPath?: string; | |
| 1330 | 1331 | }): Promise<MountedChatView> { |
| 1331 | 1332 | fixture = buildFixture(options.snapshot); |
| 1332 | 1333 | options.configureFixture?.(fixture); |
| ... | ... | @@ -1346,7 +1347,7 @@ async function mountChatView(options: { |
| 1346 | 1347 | |
| 1347 | 1348 | const router = getRouter( |
| 1348 | 1349 | createMemoryHistory({ |
| 1349 | initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], | |
| 1350 | initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], | |
| 1350 | 1351 | }), |
| 1351 | 1352 | ); |
| 1352 | 1353 | |
| ... | ... | @@ -2512,6 +2513,119 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2512 | 2513 | } |
| 2513 | 2514 | }); |
| 2514 | 2515 | |
| 2516 | it("uses the active draft route session when changing the base branch", async () => { | |
| 2517 | const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); | |
| 2518 | const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); | |
| 2519 | ||
| 2520 | useComposerDraftStore.setState({ | |
| 2521 | draftThreadsByThreadKey: { | |
| 2522 | [staleDraftId]: { | |
| 2523 | threadId: THREAD_ID, | |
| 2524 | environmentId: LOCAL_ENVIRONMENT_ID, | |
| 2525 | projectId: PROJECT_ID, | |
| 2526 | logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, | |
| 2527 | createdAt: NOW_ISO, | |
| 2528 | runtimeMode: "full-access", | |
| 2529 | interactionMode: "default", | |
| 2530 | branch: "main", | |
| 2531 | worktreePath: null, | |
| 2532 | envMode: "worktree", | |
| 2533 | }, | |
| 2534 | [activeDraftId]: { | |
| 2535 | threadId: THREAD_ID, | |
| 2536 | environmentId: LOCAL_ENVIRONMENT_ID, | |
| 2537 | projectId: PROJECT_ID, | |
| 2538 | logicalProjectKey: PROJECT_DRAFT_KEY, | |
| 2539 | createdAt: NOW_ISO, | |
| 2540 | runtimeMode: "full-access", | |
| 2541 | interactionMode: "default", | |
| 2542 | branch: "main", | |
| 2543 | worktreePath: null, | |
| 2544 | envMode: "worktree", | |
| 2545 | }, | |
| 2546 | }, | |
| 2547 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 2548 | [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, | |
| 2549 | [PROJECT_DRAFT_KEY]: activeDraftId, | |
| 2550 | }, | |
| 2551 | }); | |
| 2552 | ||
| 2553 | const mounted = await mountChatView({ | |
| 2554 | viewport: DEFAULT_VIEWPORT, | |
| 2555 | snapshot: createDraftOnlySnapshot(), | |
| 2556 | initialPath: `/draft/${activeDraftId}`, | |
| 2557 | resolveRpc: (body) => { | |
| 2558 | if (body._tag === WS_METHODS.gitListBranches) { | |
| 2559 | return { | |
| 2560 | isRepo: true, | |
| 2561 | hasOriginRemote: true, | |
| 2562 | nextCursor: null, | |
| 2563 | totalCount: 2, | |
| 2564 | branches: [ | |
| 2565 | { | |
| 2566 | name: "main", | |
| 2567 | current: true, | |
| 2568 | isDefault: true, | |
| 2569 | worktreePath: null, | |
| 2570 | }, | |
| 2571 | { | |
| 2572 | name: "release/next", | |
| 2573 | current: false, | |
| 2574 | isDefault: false, | |
| 2575 | worktreePath: null, | |
| 2576 | }, | |
| 2577 | ], | |
| 2578 | }; | |
| 2579 | } | |
| 2580 | return undefined; | |
| 2581 | }, | |
| 2582 | }); | |
| 2583 | ||
| 2584 | try { | |
| 2585 | const branchButton = await waitForElement( | |
| 2586 | () => | |
| 2587 | Array.from(document.querySelectorAll("button")).find( | |
| 2588 | (button) => button.textContent?.trim() === "From main", | |
| 2589 | ) as HTMLButtonElement | null, | |
| 2590 | 'Unable to find branch selector button with "From main".', | |
| 2591 | ); | |
| 2592 | branchButton.click(); | |
| 2593 | ||
| 2594 | const branchOption = await waitForElement( | |
| 2595 | () => | |
| 2596 | Array.from(document.querySelectorAll("span")).find( | |
| 2597 | (element) => element.textContent?.trim() === "release/next", | |
| 2598 | ) as HTMLSpanElement | null, | |
| 2599 | 'Unable to find the "release/next" branch option.', | |
| 2600 | ); | |
| 2601 | branchOption.click(); | |
| 2602 | ||
| 2603 | await vi.waitFor( | |
| 2604 | () => { | |
| 2605 | expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( | |
| 2606 | "release/next", | |
| 2607 | ); | |
| 2608 | expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( | |
| 2609 | "main", | |
| 2610 | ); | |
| 2611 | }, | |
| 2612 | { timeout: 8_000, interval: 16 }, | |
| 2613 | ); | |
| 2614 | ||
| 2615 | await vi.waitFor( | |
| 2616 | () => { | |
| 2617 | const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => | |
| 2618 | button.textContent?.trim().includes("From release/next"), | |
| 2619 | ); | |
| 2620 | expect(updatedButton).toBeTruthy(); | |
| 2621 | }, | |
| 2622 | { timeout: 8_000, interval: 16 }, | |
| 2623 | ); | |
| 2624 | } finally { | |
| 2625 | await mounted.cleanup(); | |
| 2626 | } | |
| 2627 | }); | |
| 2628 | ||
| 2515 | 2629 | it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { |
| 2516 | 2630 | const mounted = await mountChatView({ |
| 2517 | 2631 | viewport: DEFAULT_VIEWPORT, |
apps/web/src/components/ChatView.tsx+1| ... | ... | @@ -3289,6 +3289,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3289 | 3289 | <ChatHeader |
| 3290 | 3290 | activeThreadEnvironmentId={activeThread.environmentId} |
| 3291 | 3291 | activeThreadId={activeThread.id} |
| 3292 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | |
| 3292 | 3293 | activeThreadTitle={activeThread.title} |
| 3293 | 3294 | activeProjectName={activeProject?.name} |
| 3294 | 3295 | isGitRepo={isGitRepo} |
apps/web/src/components/GitActionsControl.browser.tsx+33| ... | ... | @@ -426,4 +426,37 @@ describe("GitActionsControl thread-scoped progress toast", () => { |
| 426 | 426 | host.remove(); |
| 427 | 427 | } |
| 428 | 428 | }); |
| 429 | ||
| 430 | it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { | |
| 431 | hasServerThreadRef.current = false; | |
| 432 | activeDraftThreadRef.current = { | |
| 433 | threadId: SHARED_THREAD_ID, | |
| 434 | environmentId: ENVIRONMENT_A, | |
| 435 | branch: "feature/base-branch", | |
| 436 | worktreePath: null, | |
| 437 | envMode: "worktree", | |
| 438 | }; | |
| 439 | ||
| 440 | const host = document.createElement("div"); | |
| 441 | document.body.append(host); | |
| 442 | const screen = await render( | |
| 443 | <GitActionsControl | |
| 444 | gitCwd={GIT_CWD} | |
| 445 | activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)} | |
| 446 | />, | |
| 447 | { | |
| 448 | container: host, | |
| 449 | }, | |
| 450 | ); | |
| 451 | ||
| 452 | try { | |
| 453 | await Promise.resolve(); | |
| 454 | ||
| 455 | expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); | |
| 456 | expect(setThreadBranchSpy).not.toHaveBeenCalled(); | |
| 457 | } finally { | |
| 458 | await screen.unmount(); | |
| 459 | host.remove(); | |
| 460 | } | |
| 461 | }); | |
| 429 | 462 | }); |
apps/web/src/components/GitActionsControl.tsx+22-6| ... | ... | @@ -49,7 +49,7 @@ import { |
| 49 | 49 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; |
| 50 | 50 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 51 | 51 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 52 | import { useComposerDraftStore } from "~/composerDraftStore"; | |
| 52 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | |
| 53 | 53 | import { readEnvironmentApi } from "~/environmentApi"; |
| 54 | 54 | import { readLocalApi } from "~/localApi"; |
| 55 | 55 | import { useStore } from "~/store"; |
| ... | ... | @@ -58,6 +58,7 @@ import { createThreadSelectorByRef } from "~/storeSelectors"; |
| 58 | 58 | interface GitActionsControlProps { |
| 59 | 59 | gitCwd: string | null; |
| 60 | 60 | activeThreadRef: ScopedThreadRef | null; |
| 61 | draftId?: DraftId; | |
| 61 | 62 | } |
| 62 | 63 | |
| 63 | 64 | interface PendingDefaultBranchAction { |
| ... | ... | @@ -209,7 +210,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 209 | 210 | return <InfoIcon className={iconClassName} />; |
| 210 | 211 | } |
| 211 | 212 | |
| 212 | export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { | |
| 213 | export default function GitActionsControl({ | |
| 214 | gitCwd, | |
| 215 | activeThreadRef, | |
| 216 | draftId, | |
| 217 | }: GitActionsControlProps) { | |
| 213 | 218 | const activeEnvironmentId = activeThreadRef?.environmentId ?? null; |
| 214 | 219 | const threadToastData = useMemo( |
| 215 | 220 | () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), |
| ... | ... | @@ -221,7 +226,11 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction |
| 221 | 226 | ); |
| 222 | 227 | const activeServerThread = useStore(activeServerThreadSelector); |
| 223 | 228 | const activeDraftThread = useComposerDraftStore((store) => |
| 224 | activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null, | |
| 229 | draftId | |
| 230 | ? store.getDraftSession(draftId) | |
| 231 | : activeThreadRef | |
| 232 | ? store.getDraftThreadByRef(activeThreadRef) | |
| 233 | : null, | |
| 225 | 234 | ); |
| 226 | 235 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 227 | 236 | const setThreadBranch = useStore((store) => store.setThreadBranch); |
| ... | ... | @@ -282,7 +291,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction |
| 282 | 291 | return; |
| 283 | 292 | } |
| 284 | 293 | |
| 285 | setDraftThreadContext(activeThreadRef, { | |
| 294 | setDraftThreadContext(draftId ?? activeThreadRef, { | |
| 286 | 295 | branch, |
| 287 | 296 | worktreePath: activeDraftThread.worktreePath, |
| 288 | 297 | }); |
| ... | ... | @@ -291,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction |
| 291 | 300 | activeDraftThread, |
| 292 | 301 | activeServerThread, |
| 293 | 302 | activeThreadRef, |
| 303 | draftId, | |
| 294 | 304 | setDraftThreadContext, |
| 295 | 305 | setThreadBranch, |
| 296 | 306 | ], |
| ... | ... | @@ -344,14 +354,18 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction |
| 344 | 354 | const isPullRunning = |
| 345 | 355 | useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; |
| 346 | 356 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; |
| 357 | const isSelectingWorktreeBase = | |
| 358 | !activeServerThread && | |
| 359 | activeDraftThread?.envMode === "worktree" && | |
| 360 | activeDraftThread.worktreePath === null; | |
| 347 | 361 | |
| 348 | 362 | useEffect(() => { |
| 349 | if (isGitActionRunning) { | |
| 363 | if (isGitActionRunning || isSelectingWorktreeBase) { | |
| 350 | 364 | return; |
| 351 | 365 | } |
| 352 | 366 | |
| 353 | 367 | const branchUpdate = resolveLiveThreadBranchUpdate({ |
| 354 | threadBranch: activeServerThread?.branch ?? null, | |
| 368 | threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, | |
| 355 | 369 | gitStatus: gitStatusForActions, |
| 356 | 370 | }); |
| 357 | 371 | if (!branchUpdate) { |
| ... | ... | @@ -361,8 +375,10 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction |
| 361 | 375 | persistThreadBranchSync(branchUpdate.branch); |
| 362 | 376 | }, [ |
| 363 | 377 | activeServerThread?.branch, |
| 378 | activeDraftThread?.branch, | |
| 364 | 379 | gitStatusForActions, |
| 365 | 380 | isGitActionRunning, |
| 381 | isSelectingWorktreeBase, | |
| 366 | 382 | persistThreadBranchSync, |
| 367 | 383 | ]); |
| 368 | 384 |
apps/web/src/components/chat/ChatHeader.tsx+4| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 9 | 9 | import { memo } from "react"; |
| 10 | 10 | import GitActionsControl from "../GitActionsControl"; |
| 11 | import { type DraftId } from "~/composerDraftStore"; | |
| 11 | 12 | import { DiffIcon, TerminalSquareIcon } from "lucide-react"; |
| 12 | 13 | import { Badge } from "../ui/badge"; |
| 13 | 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -19,6 +20,7 @@ import { OpenInPicker } from "./OpenInPicker"; |
| 19 | 20 | interface ChatHeaderProps { |
| 20 | 21 | activeThreadEnvironmentId: EnvironmentId; |
| 21 | 22 | activeThreadId: ThreadId; |
| 23 | draftId?: DraftId; | |
| 22 | 24 | activeThreadTitle: string; |
| 23 | 25 | activeProjectName: string | undefined; |
| 24 | 26 | isGitRepo: boolean; |
| ... | ... | @@ -44,6 +46,7 @@ interface ChatHeaderProps { |
| 44 | 46 | export const ChatHeader = memo(function ChatHeader({ |
| 45 | 47 | activeThreadEnvironmentId, |
| 46 | 48 | activeThreadId, |
| 49 | draftId, | |
| 47 | 50 | activeThreadTitle, |
| 48 | 51 | activeProjectName, |
| 49 | 52 | isGitRepo, |
| ... | ... | @@ -109,6 +112,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 109 | 112 | <GitActionsControl |
| 110 | 113 | gitCwd={gitCwd} |
| 111 | 114 | activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)} |
| 115 | {...(draftId ? { draftId } : {})} | |
| 112 | 116 | /> |
| 113 | 117 | )} |
| 114 | 118 | <Tooltip> |