From 198a637c14ae42097055dfcb2c0cb7e2a4c51f77 Mon Sep 17 00:00:00 2001 From: idevlab Date: Mon, 31 Aug 2026 22:49:15 +0800 Subject: [PATCH 1/8] fix(desktop): polish sidebar and composer interactions Adopt dnd-kit for sidebar sorting and folder moves, restore session summaries, and remove obsolete disclosure and resize affordances.\n\nAdd split motion, popover and shortcut alignment fixes, plus a unified provider/model picker inspired by T3. --- apps/desktop/bun.lock | 15 + apps/desktop/package.json | 1 + apps/desktop/src/App.tsx | 24 - .../src/environment/EnvironmentPopover.tsx | 3 +- apps/desktop/src/i18n/strings.ts | 2 - apps/desktop/src/session/Composer.tsx | 266 +++++--- apps/desktop/src/session/PaneTiles.tsx | 29 + apps/desktop/src/sidebar/SessionRail.tsx | 570 ++++++++++-------- apps/desktop/src/sidebar/sidebarDnd.tsx | 144 +++++ apps/desktop/src/styles.css | 57 +- .../tests/composerGeometryContract.test.ts | 14 + .../tests/environmentPopoverRendered.test.tsx | 8 + apps/desktop/tests/paneTiles.test.tsx | 88 +++ apps/desktop/tests/sceneChip.test.tsx | 71 +++ .../tests/sessionRailRendered.test.tsx | 153 +++-- crates/core/src/store.rs | 42 +- .../change.md | 103 ++++ .../change.md | 101 ++++ .../2026-08-31-animate-pane-splits/change.md | 114 ++++ .../change.md | 107 ++++ .../change.md | 104 ++++ .../change.md | 127 ++++ .../change.md | 110 ++++ .../change.md | 121 ++++ 24 files changed, 1928 insertions(+), 446 deletions(-) create mode 100644 apps/desktop/src/sidebar/sidebarDnd.tsx create mode 100644 docs/sdlc/changes/2026-08-31-align-environment-popover/change.md create mode 100644 docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md create mode 100644 docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md create mode 100644 docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md create mode 100644 docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md create mode 100644 docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md create mode 100644 docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index b6c6eb57..554bc7d2 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -9,6 +9,7 @@ "@blocknote/core": "^0.25.0", "@blocknote/mantine": "^0.25.0", "@blocknote/react": "^0.25.0", + "@dnd-kit/react": "0.5.0", "@excalidraw/excalidraw": "0.18.1", "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", @@ -139,6 +140,18 @@ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], + + "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="], + + "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="], + + "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="], + + "@dnd-kit/react": ["@dnd-kit/react@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw=="], + + "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="], + "@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -289,6 +302,8 @@ "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 962488cb..7a56b2c0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,6 +27,7 @@ "@blocknote/core": "^0.25.0", "@blocknote/mantine": "^0.25.0", "@blocknote/react": "^0.25.0", + "@dnd-kit/react": "0.5.0", "@excalidraw/excalidraw": "0.18.1", "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7fbbc54b..193ffa89 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1401,17 +1401,6 @@ export default function App() { const [activeFile, setActiveFile] = useState(null); const [fileReveal, setFileReveal] = useState(null); const fileRevealRequestRef = useRef(0); - // Composer geometry: how tall the document area may grow before it scrolls, and whether it has - // taken over the whole column for long-form authoring. The persisted value is the default; each - // pane then remembers its own height in-session (see composerHByPane) so resizing one tiled - // composer never drags the others with it. - const [composerH, setComposerH] = usePersistedNumber( - "codetwo.composerHeight", - 190, - ); - const [composerHByPane, setComposerHByPane] = useState>( - {}, - ); const [dockWidth, setDockWidth] = usePersistedNumber( "codetwo.dockWidth", 440, @@ -2651,11 +2640,6 @@ export default function App() { const focusedDefaultModel = defaultModel; const focusedConfigOptions = configOptions; const focusedSessionUsage = sessionUsage; - // Composer height: the persisted value is each pane's default; resizing the focused pane also - // persists it so new panes inherit the latest preference. - const focusedComposerHeight = composerH; - const persistComposerHeight = setComposerH; - const activeWorktreeState = useMemo(() => { const stored = sessions.find((session) => session.id === activeSession) ?? @@ -7947,12 +7931,6 @@ export default function App() { // Usage is polled only for the focused session; a background pane hides its cost // segment rather than borrow the focused figures. const sessionUsage = paneFocused ? focusedSessionUsage : null; - // Each pane keeps its own composer height so a resize stays local to that tile. - const composerH = composerHByPane[paneId] ?? focusedComposerHeight; - const setComposerH = (h: number) => { - setComposerHByPane((prev) => ({ ...prev, [paneId]: h })); - if (paneFocused) persistComposerHeight(h); - }; const activeInteractionCapabilities = activeSession ? interactionCapabilities[activeSession] ?? null : null; @@ -8325,8 +8303,6 @@ export default function App() { }} docMode={docMode} onDocMode={toggleDocMode} - height={composerH} - onHeight={setComposerH} boundsRef={mainRef} models={activeSession === null ? providers.find((candidate) => candidate.id === provider)?.models ?? [] diff --git a/apps/desktop/src/environment/EnvironmentPopover.tsx b/apps/desktop/src/environment/EnvironmentPopover.tsx index dfb15f68..612cf2c2 100644 --- a/apps/desktop/src/environment/EnvironmentPopover.tsx +++ b/apps/desktop/src/environment/EnvironmentPopover.tsx @@ -251,8 +251,7 @@ export function EnvironmentPopover({ /> = { "composer.runEmpty": "先写点什么", "composer.loadingSession": "请等待会话加载完成", "composer.toSend": "{key} 发送", - "composer.grip": "拖动调整高度 · 双击整页", "composer.cliNotFound": "未找到 CLI", "checkout.title": "检出位置", "checkout.choose": "选择下一个会话的运行位置", diff --git a/apps/desktop/src/session/Composer.tsx b/apps/desktop/src/session/Composer.tsx index ff4ae904..c7f9f779 100644 --- a/apps/desktop/src/session/Composer.tsx +++ b/apps/desktop/src/session/Composer.tsx @@ -65,7 +65,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useResizeHandle } from "@/components/ui/use-resize-handle"; import { useT } from "../i18n"; import { cn } from "@/lib/utils"; @@ -85,10 +84,7 @@ interface ComposerProps { /** Full-page authoring: the document takes the whole column and the transcript steps aside. */ docMode: boolean; onDocMode: (v: boolean) => void; - /** Height of the document area in compact mode, in px — dragged by the grip, persisted. */ - height: number; - onHeight: (n: number) => void; - /** The column the composer lives in; bounds the drag so it can't swallow the transcript. */ + /** The column the composer lives in; bounds compact content so it can't swallow the transcript. */ boundsRef: React.MutableRefObject; /** What the agent reported it can run. Empty until a session exists, or if it reports none. */ models: ModelChoice[]; @@ -904,6 +900,7 @@ export function ModelPicker({ configOptions, onConfigOption, hasSession, + providerConfig, showWhenUnavailable = false, disabled = false, }: { @@ -915,6 +912,8 @@ export function ModelPicker({ configOptions: ConfigOptionInfo[]; onConfigOption: (configId: string, value: string) => void; hasSession: boolean; + /** Primary Composer only: fold Provider browsing into the model surface. */ + providerConfig?: SessionConfig; /** Keep an explicit model affordance while a host surface is waiting for provider metadata. */ showWhenUnavailable?: boolean; /** A live turn owns its provider runtime; model and effort changes wait until it ends. */ @@ -924,25 +923,60 @@ export function ModelPicker({ const [modelOpen, setModelOpen] = useState(false); const [modelSearch, setModelSearch] = useState(""); const [effortOpen, setEffortOpen] = useState(false); + const [browseProvider, setBrowseProvider] = useState(provider); + const modelTriggerRef = useRef(null); + const providerRailEnabled = providerConfig !== undefined; + const providerRegistry = providerConfig + ? providerConfig.providers.length > 0 ? providerConfig.providers : fallbackProviders() + : []; + const providerChoices = providerConfig + ? providerRegistry.filter( + (candidate) => candidate.enabled !== false || candidate.id === providerConfig.provider, + ) + : []; + const pickerProvider = providerRailEnabled ? browseProvider : provider; + const pickerProviderInfo = providerChoices.find((candidate) => candidate.id === pickerProvider); + const browsingCurrentProvider = pickerProvider === provider; + const pickerModels = browsingCurrentProvider ? models : pickerProviderInfo?.models ?? []; + const pickerConfigOptions = browsingCurrentProvider ? configOptions : []; + const pickerCurrent = browsingCurrentProvider ? current : null; + const pickerDefaultModel = browsingCurrentProvider ? defaultModel : null; const families = useMemo(() => groupModels(models), [models]); - const { favorites, toggle: toggleFavorite } = useProviderModelFavorites(provider); - const { hidden: hiddenModels } = useProviderModelPreferences(provider); + const pickerFamilies = useMemo(() => groupModels(pickerModels), [pickerModels]); + const { favorites, toggle: toggleFavorite } = useProviderModelFavorites(pickerProvider); + const { hidden: hiddenModels } = useProviderModelPreferences(pickerProvider); useEffect(() => { if (disabled) { setModelOpen(false); setEffortOpen(false); } }, [disabled]); - if (!hasSession && models.length === 0 && !showWhenUnavailable) return null; + useEffect(() => { + if (!modelOpen) setBrowseProvider(provider); + }, [modelOpen, provider]); + useEffect(() => { + if (!providerRailEnabled) return; + const openProviderModelPicker = () => { + setBrowseProvider(provider); + setModelOpen(true); + window.setTimeout(() => modelTriggerRef.current?.focus(), 0); + }; + window.addEventListener("codetwo-open-provider-picker", openProviderModelPicker); + return () => window.removeEventListener("codetwo-open-provider-picker", openProviderModelPicker); + }, [provider, providerRailEnabled]); + if (!providerRailEnabled && !hasSession && models.length === 0 && !showWhenUnavailable) return null; const effortName = (e: Effort | null) => (e ? t(`effort.${e}` as "effort.low") : t("composer.default")); const modelOpt = configOptions.find((o) => o.category === "model" || o.id === "model"); + const pickerModelOpt = pickerConfigOptions.find((o) => o.category === "model" || o.id === "model"); const effortOpt = configOptions.find( (o) => o.category === "thought_level" || o.id === "effort" || o.id === "reasoning_effort", ); const activeFamily = familyOf(families, current); const activeVariant = variantOf(families, current); + const pickerActiveFamily = familyOf(pickerFamilies, pickerCurrent); + const pickerActiveVariant = variantOf(pickerFamilies, pickerCurrent); let modelLabel: string; let modelRows: PickerRow[]; @@ -950,30 +984,37 @@ export function ModelPicker({ let effortRows: PickerRow[] = []; if (modelOpt) { - // The adapter described its own selectors; show them as described. + // Keep the trigger anchored to the active Provider even while the popup browses another one. modelLabel = modelOpt.choices.find((c) => c.id === modelOpt.current)?.name || modelOpt.current || t("composer.defaultModel"); - modelRows = modelOpt.choices.map((c) => ({ + } else { + const active = models.find((m) => m.id === current); + modelLabel = activeFamily?.label ?? active?.name ?? current ?? t("composer.defaultModel"); + } + + if (pickerModelOpt) { + // The active adapter described its own selector; preserve that provider-owned path. + modelRows = pickerModelOpt.choices.map((c) => ({ key: c.id, label: c.name, detail: c.description, - isDefault: c.id === defaultModel, - selected: c.id === modelOpt.current, - select: () => onConfigOption(modelOpt.id, c.id), + isDefault: c.id === pickerDefaultModel, + selected: c.id === pickerModelOpt.current, + select: () => onConfigOption(pickerModelOpt.id, c.id), })); } else { - // Flat list: regroup by the effort suffix parsed out of each name. - const active = models.find((m) => m.id === current); - modelLabel = activeFamily?.label ?? active?.name ?? current ?? t("composer.defaultModel"); - modelRows = families.map((f) => ({ + // Flat lists from either the active runtime or another registry Provider share one projection. + modelRows = pickerFamilies.map((f) => ({ key: f.key, label: f.label, detail: f.variants[0]?.choice.description, - isDefault: f.variants.some((v) => v.choice.id === defaultModel), - selected: f === activeFamily, - select: () => onModel(pickVariant(f, activeVariant?.effort ?? null, defaultModel).id), + isDefault: f.variants.some((v) => v.choice.id === pickerDefaultModel), + selected: f === pickerActiveFamily, + select: () => onModel( + pickVariant(f, pickerActiveVariant?.effort ?? null, pickerDefaultModel).id, + ), })); } @@ -1022,6 +1063,46 @@ export function ModelPicker({ onToggleFavorite={() => toggleFavorite(row.key)} /> ); + const modelMenu = modelRows.length === 0 ? ( +

+ {t("composer.noModels")} +

+ ) : ( + <> + setModelSearch("")} + onChange={(event) => setModelSearch(event.target.value)} + className="mb-1" + /> +
+ {filteredModelRows.length === 0 ? ( +

+ {normalizedSearch ? t("composer.noMatchingModels") : t("composer.noVisibleModels")} +

+ ) : favoriteRows.length > 0 ? ( + <> + {t("composer.favorites")} + {favoriteRows.map(renderModelRow)} + {regularRows.length > 0 ? {t("composer.model")} : null} + {regularRows.map(renderModelRow)} + + ) : ( + <> + {t("composer.model")} + {filteredModelRows.map(renderModelRow)} + + )} +
+ + ); return ( <> @@ -1035,6 +1116,7 @@ export function ModelPicker({ - {modelRows.length === 0 ? ( -

- {t("composer.noModels")} -

- ) : ( - <> - setModelSearch("")} - onChange={(event) => setModelSearch(event.target.value)} - className="mb-1" - /> + {providerConfig ? ( +
- {filteredModelRows.length === 0 ? ( -

- {normalizedSearch ? t("composer.noMatchingModels") : t("composer.noVisibleModels")} + {providerChoices.map((candidate) => { + const selected = candidate.id === pickerProvider; + const unavailable = providerConfig.providersStatus === "ready" && !candidate.available; + return ( + + { + setBrowseProvider(candidate.id); + setModelSearch(""); + providerConfig.onProvider(candidate.id); + }} + > + + + ); + })} +

+
+ {providerConfig.providersStatus === "loading" ? ( +

+ {t("config.providersLoading")}

- ) : favoriteRows.length > 0 ? ( - <> - {t("composer.favorites")} - {favoriteRows.map(renderModelRow)} - {regularRows.length > 0 ? ( - {t("composer.model")} - ) : null} - {regularRows.map(renderModelRow)} - - ) : ( - <> - {t("composer.model")} - {filteredModelRows.map(renderModelRow)} - - )} + ) : null} + {providerConfig.providersStatus === "error" ? ( +
+ + {t("config.providersLoadFailed")} + + +
+ ) : null} + {modelMenu}
- +
+ ) : ( + modelMenu )}
@@ -1176,7 +1301,6 @@ export function SessionControls({
{config.scenesEnabled ? : null} - @@ -1250,8 +1375,6 @@ export function Composer({ hero, docMode, onDocMode, - height, - onHeight, boundsRef, models, currentModel, @@ -1344,16 +1467,7 @@ export function Composer({ window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [boundsRef]); - const applied = Math.min(height, maxHeight); - - const resizeHandle = useResizeHandle({ - axis: "y", - direction: -1, - value: applied, - min: 72, - max: maxHeight, - onResize: onHeight, - }); + const applied = Math.min(190, maxHeight); const controls = ( <> @@ -1616,16 +1730,6 @@ export function Composer({ : "rounded-composer bg-card shadow-raised transition-shadow duration-feedback ease-enter focus-within:focus-ring-inset", )} > - {/* Grip: drag for any height, double-click for the full page. Meaningless once the - document owns the column, so it's hidden — but kept mounted to preserve the tree. */} -
onDocMode(true)} - title={t("composer.grip")} - {...resizeHandle} - /> -
`${value * 100}%`; +function paneEntranceEdge(node: PaneNode, paneId: string): PaneEdge | null { + if (node.kind === "leaf") return null; + if (node.a.kind === "leaf" && node.a.id === paneId) { + return node.direction === "row" ? "left" : "top"; + } + if (node.b.kind === "leaf" && node.b.id === paneId) { + return node.direction === "row" ? "right" : "bottom"; + } + return paneEntranceEdge(node.a, paneId) ?? paneEntranceEdge(node.b, paneId); +} + export interface PaneTilesProps { layout: PaneLayout; /** Renders a leaf's content. Called once per pane; the node is kept mounted across relayouts. */ @@ -41,6 +54,18 @@ export function PaneTiles({ const paneIds = listPanes(layout.root); const rects = computePaneRects(layout.root); const dividers = computeDividers(layout.root); + const knownPaneIdsRef = useRef(new Set(paneIds)); + const entranceEdgesRef = useRef(new Map()); + const currentPaneIds = new Set(paneIds); + for (const paneId of paneIds) { + if (knownPaneIdsRef.current.has(paneId)) continue; + const edge = paneEntranceEdge(layout.root, paneId); + if (edge) entranceEdgesRef.current.set(paneId, edge); + } + for (const paneId of entranceEdgesRef.current.keys()) { + if (!currentPaneIds.has(paneId)) entranceEdgesRef.current.delete(paneId); + } + knownPaneIdsRef.current = currentPaneIds; // A lone pane fills the workspace, so a focus ring would just outline the whole column; only // show it once tiling actually splits the space. const multiPane = paneIds.length > 1; @@ -54,6 +79,7 @@ export function PaneTiles({ const rect = rects.get(paneId); if (!rect) return null; const focused = paneId === layout.focused; + const entranceEdge = entranceEdgesRef.current.get(paneId); const style: CSSProperties = { position: "absolute", left: percent(rect.x), @@ -66,8 +92,11 @@ export function PaneTiles({ key={paneId} data-pane-id={paneId} data-focused={focused || undefined} + data-pane-entrance={entranceEdge} className={cn( "overflow-hidden", + entranceEdge && "pane-tile-enter", + entranceEdge && `pane-tile-enter-${entranceEdge}`, focused && multiPane && "outline outline-1 -outline-offset-1 outline-ring", )} style={style} diff --git a/apps/desktop/src/sidebar/SessionRail.tsx b/apps/desktop/src/sidebar/SessionRail.tsx index 49c6887f..cfa337c4 100644 --- a/apps/desktop/src/sidebar/SessionRail.tsx +++ b/apps/desktop/src/sidebar/SessionRail.tsx @@ -6,11 +6,18 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, useState, type HTMLAttributes, type ReactElement, type ReactNode, } from "react"; +import { + DragDropProvider, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/react"; import { Archive, ArchiveRestore, @@ -49,6 +56,7 @@ import { import { githubCurrentPullRequest, openNativePath, + providerLabel, type GitHubPullRequest, type Project, type SessionInfo, @@ -91,6 +99,7 @@ import { useResizeHandle } from "@/components/ui/use-resize-handle"; import { useT } from "../i18n"; import { usePersistedBoolean } from "@/lib/persist"; import { cn } from "@/lib/utils"; +import { ProviderIcon } from "../providers/ProviderIcon"; import { sessionActivity } from "../session/sessionEvents"; import type { QuickQuotaSummary } from "../usage/quickQuota"; import { useToast } from "../ui/toast"; @@ -122,37 +131,30 @@ import { loadSidebarPullRequests, type SidebarPullRequestStatus, } from "./sidebarGitStatus"; - -const SIDEBAR_DRAG_TYPE = "application/x-codetwo-sidebar-item"; - -type SidebarDragItem = - | { kind: "task"; id: string } - | { kind: "section"; id: string } - | { kind: "project"; id: string }; - -function writeSidebarDrag(event: React.DragEvent, item: SidebarDragItem): void { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData(SIDEBAR_DRAG_TYPE, JSON.stringify(item)); - event.dataTransfer.setData("text/plain", item.id); -} - -function readSidebarDrag(event: React.DragEvent): SidebarDragItem | null { - try { - const value = JSON.parse(event.dataTransfer.getData(SIDEBAR_DRAG_TYPE)) as SidebarDragItem; - if ( - (value.kind === "task" || value.kind === "section" || value.kind === "project") - && value.id - ) return value; - } catch { - // Ignore drags from other applications and older renderers. - } - return null; -} +import { + SidebarDropZone, + SidebarSortable, + sidebarDndData, + sidebarProjectSectionFromGroup, + sidebarSortableSnapshot, + type SidebarDndData, + type SidebarDragItem, +} from "./sidebarDnd"; type ContextMenuTriggerElement = ReactElement<{ render: ReactElement>; }>; +/** Compact relative age for a dense Task row. */ +function shortAge(timestamp: number, now: number): string { + const seconds = Math.max(0, Math.floor((now - timestamp) / 1000)); + if (seconds < 60) return "now"; + if (seconds < 3_600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86_400) return `${Math.floor(seconds / 3_600)}h`; + if (seconds < 7 * 86_400) return `${Math.floor(seconds / 86_400)}d`; + return `${Math.floor(seconds / (7 * 86_400))}w`; +} + function RailUtilityButton({ label, selected = false, @@ -351,10 +353,17 @@ export function SessionRail({ const [sectionDraft, setSectionDraft] = useState(""); const [renamingSection, setRenamingSection] = useState<{ id: string; name: string } | null>(null); const [dragItem, setDragItem] = useState(null); + const dragTargetRef = useRef(null); + const [ageNow, setAgeNow] = useState(() => Date.now()); const [projectOrganization, setProjectOrganization] = useState(() => loadSidebarProjects(typeof localStorage === "undefined" ? null : localStorage), ); + useEffect(() => { + const interval = window.setInterval(() => setAgeNow(Date.now()), 60_000); + return () => window.clearInterval(interval); + }, []); + useEffect(() => { saveSidebarTaskSections( typeof localStorage === "undefined" ? null : localStorage, @@ -735,6 +744,114 @@ export function SessionRail({ setTaskSections((current) => moveSidebarTaskSection(current, section.id, beforeId)); }, [taskSections.sections]); + const handleSidebarDragStart = useCallback((event: DragStartEvent) => { + dragTargetRef.current = null; + const source = sidebarDndData(event.operation.source?.data); + setDragItem(source?.item ?? null); + }, []); + + const handleSidebarDragOver = useCallback((event: DragOverEvent) => { + const source = sidebarDndData(event.operation.source?.data)?.item; + const target = sidebarDndData(event.operation.target?.data); + if (!source || !target) return; + if (target.item?.kind === source.kind && target.item.id === source.id) return; + dragTargetRef.current = target; + }, []); + + const handleSidebarDragEnd = useCallback((event: DragEndEvent) => { + setDragItem(null); + const lastTarget = dragTargetRef.current; + dragTargetRef.current = null; + if (event.canceled) return; + + const source = sidebarDndData(event.operation.source?.data)?.item; + const sortable = sidebarSortableSnapshot(event.operation.source); + const eventTarget = sidebarDndData(event.operation.target?.data); + const target = source && eventTarget?.item?.kind === source.kind + && eventTarget.item.id === source.id + ? lastTarget + : eventTarget ?? lastTarget; + if (!source || !target) return; + + if (source.kind === "section") { + if (target.item?.kind === "section") { + setTaskSections((current) => + moveSidebarTaskSection(current, source.id, target.item!.id), + ); + } else if (target.location.kind === "sections") { + setTaskSections((current) => moveSidebarTaskSection(current, source.id, null)); + } + return; + } + + if (source.kind === "project") { + const sortableSectionId = sortable + ? sidebarProjectSectionFromGroup(sortable.group) + : undefined; + if ( + sortableSectionId !== undefined + && sortable + && ( + sortable.group !== sortable.initialGroup + || sortable.index !== sortable.initialIndex + ) + ) { + const destination = projectPathsForSection(sortableSectionId) + .filter((path) => path !== source.id); + dropProject( + source.id, + sortableSectionId, + destination[Math.min(sortable.index, destination.length)] ?? null, + ); + return; + } + if (target.item?.kind === "section") { + dropProject(source.id, target.item.id, null); + } else if (target.item?.kind === "project" && target.location.kind === "projects") { + dropProject(source.id, target.location.sectionId, target.item.id); + } else if (target.location.kind === "section") { + dropProject(source.id, target.location.sectionId, null); + } else if (target.location.kind === "projects") { + dropProject(source.id, target.location.sectionId, null); + } + return; + } + + let sectionId: string | null; + let projectPath: string | null; + let beforeTaskId: string | null = null; + if (target.item?.kind === "section") { + sectionId = target.item.id; + projectPath = null; + } else if (target.item?.kind === "project") { + sectionId = null; + projectPath = target.item.id; + } else if (target.location.kind === "section") { + sectionId = target.location.sectionId; + projectPath = null; + } else if (target.location.kind === "tasks") { + sectionId = target.location.sectionId; + projectPath = target.location.projectPath; + beforeTaskId = target.item?.kind === "task" ? target.item.id : null; + } else { + return; + } + + const destinationTaskIds = projectPath + ? taskIdsForProject(projectPath) + : taskIdsForSection(sectionId); + dropTask( + source.id, + sectionId, + beforeTaskId, + destinationTaskIds, + projectPath + ? projectTaskOrderKey(projectPath) + : sectionId ?? UNSECTIONED_TASK_ORDER_KEY, + projectPath, + ); + }, [dropProject, dropTask, projectPathsForSection, taskIdsForProject, taskIdsForSection]); + /** One quiet source-list row: task title, workspace identity, and only actionable status. */ const sessionRow = (s: SessionInfo, isArchived: boolean, showProjectIdentity = true) => { const activity = sessionActivity(s).state; @@ -752,10 +869,14 @@ export function SessionRail({ const preview = previews[s.id]?.trim(); // Structured empty documents can serialize to a bullet or another punctuation-only fragment. // It carries no glanceable meaning and should not consume a whole rail line. Neither does a - // preview that merely restates the title, which is typical of single-prompt threads. + // Preview rows are Agent-only at the Core boundary, so a meaningful reply remains useful even + // when it happens to match the Task title. const hasUsefulPreview = Boolean( - preview && /[\p{L}\p{N}]/u.test(preview) && preview !== s.title.trim(), + preview && /[\p{L}\p{N}]/u.test(preview), ); + const lastActiveAt = s.last_active_at && s.last_active_at > 0 + ? s.last_active_at + : s.created_at; const workspacePath = s.project_path ?? s.worktree_path ?? s.cwd; const workspaceName = (s.project_path ? projectNames.get(s.project_path) : null) ?? workspacePath.split(/[\\/]/).filter(Boolean).pop() @@ -791,9 +912,6 @@ export function SessionRail({ const currentTaskIds = currentProjectPath ? taskIdsForProject(currentProjectPath) : taskIdsForSection(currentSectionId); - const currentTaskOrderKey = currentProjectPath - ? projectTaskOrderKey(currentProjectPath) - : currentSectionId ?? UNSECTIONED_TASK_ORDER_KEY; const currentTaskIndex = currentTaskIds.indexOf(s.id); const canMoveUp = !isArchived && currentTaskIndex > 0; const canMoveDown = !isArchived && currentTaskIndex >= 0 && currentTaskIndex < currentTaskIds.length - 1; @@ -976,50 +1094,25 @@ export function SessionRail({ }; return ( - + {({ sourceRef, targetRef, isDragging, isDropTarget }) => ( + { - if ( - event.target instanceof Element - && event.target.closest("input, [data-session-actions] button") - ) { - event.preventDefault(); - return; - } - const item = { kind: "task", id: s.id } as const; - writeSidebarDrag(event, item); - setDragItem(item); - }} - onDragEnd={() => setDragItem(null)} - onDragOver={(event) => { - if (readSidebarDrag(event)?.kind !== "task" || isArchived) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (item?.kind !== "task" || isArchived) return; - event.preventDefault(); - event.stopPropagation(); - dropTask( - item.id, - currentSectionId, - s.id, - currentTaskIds, - currentTaskOrderKey, - currentProjectPath, - ); - }} data-session-archive-motion={archiveMotion.has(s.id) ? isArchived ? "restore" : "archive" : undefined} @@ -1039,10 +1132,10 @@ export function SessionRail({ )} >
- {hasUsefulPreview && ( -
+ - {preview} -
- )} + + + {hasUsefulPreview ? ( + + {preview} + + ) : ( +
- {/* Workspace identity closes the hierarchy; provider and completed state stay quiet. */} + {/* Workspace identity and Git provenance close the hierarchy. */}
+ )} + ); }; @@ -1345,84 +1466,68 @@ export function SessionRail({ const canMoveUp = projectIndex > 0; const canMoveDown = projectIndex >= 0 && projectIndex < paths.length - 1; const open = projectOrganization.collapsed[project.path] !== true; - const taskIds = rows.map((row) => row.id); const moveToSection = (nextSectionId: string | null) => dropProject(project.path, nextSectionId, null); return ( - + {({ ref: taskDropRef, isDropTarget: isTaskDropTarget }) => ( + + {({ sourceRef, targetRef, isDragging, isDropTarget }) => ( +
+ setProjectOrganization((current) => setSidebarProjectCollapsed(current, project.path, !nextOpen), ) } - data-project-group={project.path} >
{ - if (event.target instanceof Element && event.target.closest("[data-project-actions]")) { - event.preventDefault(); - return; - } - const item = { kind: "project", id: project.path } as const; - writeSidebarDrag(event, item); - setDragItem(item); - }} - onDragEnd={() => setDragItem(null)} - onDragOver={(event) => { - const item = readSidebarDrag(event); - if (!item || item.kind === "section") return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (!item || item.kind === "section") return; - event.preventDefault(); - event.stopPropagation(); - if (item.kind === "project") { - dropProject(item.id, sectionId, project.path); - } else { - dropTask( - item.id, - null, - null, - taskIds, - projectTaskOrderKey(project.path), - project.path, - ); - } - }} + data-sidebar-drop-target={isDropTarget ? "true" : undefined} className="group/project relative flex min-h-control items-center rounded-control pr-1 transition-[background-color,opacity] hover:bg-fill-quiet data-[sidebar-dragging=true]:opacity-45" > + } data-project-toggle={project.path} - draggable title={t(open ? "rail.hideProject" : "rail.showProject", { name: project.name })} className="flex min-w-0 flex-1 items-center gap-2 rounded-control px-2 text-ui leading-4 outline-none focus-visible:focus-ring-inset" > { - if (readSidebarDrag(event)?.kind !== "task") return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (item?.kind !== "task") return; - event.preventDefault(); - event.stopPropagation(); - dropTask( - item.id, - null, - null, - taskIds, - projectTaskOrderKey(project.path), - project.path, - ); - }} > {rows.length > 0 ? (
@@ -1501,6 +1587,11 @@ export function SessionRail({ )} +
+ )} + + )} + ); }; @@ -1519,8 +1610,34 @@ export function SessionRail({ await Promise.all(tasks.map((row) => Promise.resolve(onArchive(row.id, true)))); }; return ( - + {({ ref: projectDropRef, isDropTarget: isProjectDropTarget }) => ( + + {({ ref: taskDropRef, isDropTarget: isTaskDropTarget }) => ( + + {({ sourceRef, targetRef, isDragging, isDropTarget }) => ( +
+ setTaskSections((current) => @@ -1529,47 +1646,11 @@ export function SessionRail({ } >
{ - if (event.target instanceof Element && event.target.closest("input, [data-task-section-actions]")) { - event.preventDefault(); - return; - } - const item = { kind: "section", id: section.id } as const; - writeSidebarDrag(event, item); - setDragItem(item); - }} - onDragEnd={() => setDragItem(null)} - onDragOver={(event) => { - const item = readSidebarDrag(event); - if (!item) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (!item) return; - event.preventDefault(); - event.stopPropagation(); - if (item.kind === "section") { - setTaskSections((current) => - moveSidebarTaskSection(current, item.id, section.id), - ); - setDragItem(null); - } else if (item.kind === "project") { - dropProject(item.id, section.id, null); - } else { - dropTask( - item.id, - section.id, - null, - rows.map((row) => row.id), - section.id, - ); - } - }} + data-sidebar-drop-target={ + isDropTarget || isProjectDropTarget ? "true" : undefined + } className="group/section relative flex min-h-control-mini items-center pr-2 pb-1 pt-2 transition-opacity data-[sidebar-dragging=true]:opacity-45" > {renamingSection?.id === section.id ? ( @@ -1598,7 +1679,6 @@ export function SessionRail({ <> } + render={ +
{ - const item = readSidebarDrag(event); - if (!item || item.kind === "section") return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (!item || item.kind === "section") return; - event.preventDefault(); - if (item.kind === "project") { - dropProject(item.id, section.id, null); - } else { - dropTask( - item.id, - section.id, - null, - rows.map((row) => row.id), - section.id, - ); - } - }} > {sectionProjectRows.length > 0 || rows.length > 0 ? (
@@ -1711,6 +1779,13 @@ export function SessionRail({ )} +
+ )} + + )} + + )} + ); }; @@ -1726,6 +1801,11 @@ export function SessionRail({ )} style={{ width: collapsed ? 0 : applied }} > + {/* Pinned to the open width so the content doesn't reflow while the pane sweeps. */}
{!collapsed && ( @@ -1768,7 +1848,7 @@ export function SessionRail({ size="row" focusStyle="inset" data-rail-search - className="mx-2 mb-1 h-control shrink-0 gap-2 bg-fill-quiet px-2 text-muted-foreground" + className="mx-2 mb-1 h-control w-auto shrink-0 gap-2 bg-fill-quiet px-2 text-muted-foreground" aria-label={t("rail.searchChats")} onClick={onOpenSearch} > @@ -1889,41 +1969,34 @@ export function SessionRail({

) : ( <> + + {({ ref, isDropTarget }) => (
{ - if (readSidebarDrag(event)?.kind !== "section") return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (item?.kind !== "section") return; - event.preventDefault(); - setTaskSections((current) => moveSidebarTaskSection(current, item.id, null)); - setDragItem(null); - }} + data-sidebar-drop-target={isDropTarget ? "true" : undefined} > {taskSections.sections.map(renderManualSection)}
+ )} +
{rootProjects.length > 0 || dragItem?.kind === "project" ? ( + + {({ ref, isDropTarget }) => (
{ - if (readSidebarDrag(event)?.kind !== "project") return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (item?.kind !== "project") return; - event.preventDefault(); - dropProject(item.id, null, null); - }} > {rootProjects.map(renderProject)}
+ )} +
) : null} {creatingSectionFor !== undefined ? (
@@ -1950,32 +2023,24 @@ export function SessionRail({
) : null} {unsectioned.length > 0 || dragItem?.kind === "task" ? ( + + {({ ref, isDropTarget }) => (
{ - if (readSidebarDrag(event)?.kind !== "task") return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={(event) => { - const item = readSidebarDrag(event); - if (item?.kind !== "task") return; - event.preventDefault(); - dropTask( - item.id, - null, - null, - unsectioned.map((session) => session.id), - UNSECTIONED_TASK_ORDER_KEY, - ); - }} > {unsectioned.map((session) => sessionRow(session, false))}
+ )} +
) : null} {archived.length > 0 && ( @@ -2052,6 +2117,7 @@ export function SessionRail({ ) : null}
+ ); } diff --git a/apps/desktop/src/sidebar/sidebarDnd.tsx b/apps/desktop/src/sidebar/sidebarDnd.tsx new file mode 100644 index 00000000..d2289cc9 --- /dev/null +++ b/apps/desktop/src/sidebar/sidebarDnd.tsx @@ -0,0 +1,144 @@ +import { useDroppable } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; +import type { ReactNode } from "react"; + +export type SidebarDragItem = + | { kind: "task"; id: string } + | { kind: "section"; id: string } + | { kind: "project"; id: string }; + +export type SidebarDropLocation = + | { kind: "sections" } + | { kind: "section"; sectionId: string } + | { kind: "projects"; sectionId: string | null } + | { kind: "tasks"; sectionId: string | null; projectPath: string | null }; + +export interface SidebarDndData { + item?: SidebarDragItem; + location: SidebarDropLocation; +} + +export interface SidebarSortableSnapshot { + group: string; + initialGroup: string; + index: number; + initialIndex: number; +} + +interface DndRenderState { + ref: (element: Element | null) => void; + sourceRef?: (element: Element | null) => void; + targetRef?: (element: Element | null) => void; + isDragging: boolean; + isDropTarget: boolean; +} + +function dndId(prefix: string, item: SidebarDragItem | undefined, location: SidebarDropLocation) { + if (item) return `${prefix}:${item.kind}:${item.id}`; + if (location.kind === "section") return `${prefix}:section:${location.sectionId}`; + if (location.kind === "projects") return `${prefix}:projects:${location.sectionId ?? "root"}`; + if (location.kind === "tasks") { + return `${prefix}:tasks:${location.sectionId ?? "root"}:${location.projectPath ?? "none"}`; + } + return `${prefix}:sections`; +} + +function dndGroup(item: SidebarDragItem, location: SidebarDropLocation) { + if (item.kind === "section") return "sidebar-sections"; + if (item.kind === "project" && location.kind === "projects") { + return `sidebar-projects:${location.sectionId ?? "root"}`; + } + if (item.kind === "task" && location.kind === "tasks") { + return `sidebar-tasks:${location.sectionId ?? "root"}:${location.projectPath ?? "none"}`; + } + return `sidebar-${item.kind}`; +} + +export function SidebarSortable({ + item, + location, + index, + accept, + collisionPriority = 0, + disabled = false, + children, +}: { + item: SidebarDragItem; + location: SidebarDropLocation; + index: number; + accept: SidebarDragItem["kind"] | SidebarDragItem["kind"][]; + collisionPriority?: number; + disabled?: boolean; + children: (state: DndRenderState) => ReactNode; +}) { + const sortable = useSortable({ + id: dndId("item", item, location), + index, + group: dndGroup(item, location), + type: item.kind, + accept, + collisionPriority, + disabled, + data: { item, location }, + }); + + return children({ + ref: sortable.ref, + sourceRef: sortable.sourceRef, + targetRef: sortable.targetRef, + isDragging: sortable.isDragging, + isDropTarget: sortable.isDropTarget, + }); +} + +export function SidebarDropZone({ + location, + accept, + collisionPriority = -1, + children, +}: { + location: SidebarDropLocation; + accept: SidebarDragItem["kind"] | SidebarDragItem["kind"][]; + collisionPriority?: number; + children: (state: DndRenderState) => ReactNode; +}) { + const acceptKey = Array.isArray(accept) ? [...accept].sort().join("-") : accept; + const droppable = useDroppable({ + id: dndId(`zone:${acceptKey}`, undefined, location), + accept, + collisionPriority, + data: { location }, + }); + + return children({ + ref: droppable.ref, + isDragging: false, + isDropTarget: droppable.isDropTarget, + }); +} + +export function sidebarDndData(value: unknown): SidebarDndData | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if (!candidate.location || typeof candidate.location !== "object") return null; + return candidate as SidebarDndData; +} + +export function sidebarSortableSnapshot(value: unknown): SidebarSortableSnapshot | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if ( + typeof candidate.group !== "string" + || typeof candidate.initialGroup !== "string" + || typeof candidate.index !== "number" + || typeof candidate.initialIndex !== "number" + ) return null; + return candidate as SidebarSortableSnapshot; +} + +export function sidebarProjectSectionFromGroup(group: string): string | null | undefined { + const prefix = "sidebar-projects:"; + if (!group.startsWith(prefix)) return undefined; + const sectionId = group.slice(prefix.length); + return sectionId === "root" ? null : sectionId; +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ab2e1098..1bf17f33 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -644,6 +644,32 @@ html[data-appearance-pointer-cursors="false"] animation: slide-in-right var(--motion-normal) var(--ease-standard) both; } + @keyframes pane-tile-enter { + from { + opacity: 0; + transform: translate3d(var(--pane-enter-x, 0), var(--pane-enter-y, 0), 0); + } + to { + opacity: 1; + transform: none; + } + } + .pane-tile-enter { + animation: pane-tile-enter var(--motion-normal) var(--ease-standard); + } + .pane-tile-enter-left { + --pane-enter-x: -12px; + } + .pane-tile-enter-right { + --pane-enter-x: 12px; + } + .pane-tile-enter-top { + --pane-enter-y: -12px; + } + .pane-tile-enter-bottom { + --pane-enter-y: 12px; + } + /* ---- floating layers --------------------------------------------------------------------- The shadcn components ship `animate-in`/`fade-in-0`-style utilities from a Tailwind plugin this project doesn't carry, so every overlay used to pop into place fully formed. These are @@ -1022,36 +1048,6 @@ html[data-appearance-pointer-cursors="false"] font-weight: 600; } - /* The composer's top rim: drag it for any height, double-click for the full column. The grabber - stays invisible until you're near it, so the resting state reads as a plain card. */ - .composer-grip { - height: 11px; - flex-shrink: 0; - cursor: row-resize; - position: relative; - touch-action: none; - user-select: none; - } - .composer-grip::after { - content: ""; - position: absolute; - left: 50%; - top: 4px; - transform: translateX(-50%); - width: 32px; - height: 3px; - border-radius: var(--ds-radius-control); - background: var(--muted-foreground); - opacity: 0; - transition: opacity var(--motion-fast) ease-out; - } - .composer-card:hover .composer-grip::after { - opacity: 0.35; - } - .composer-grip:hover::after { - opacity: 0.7; - } - /* The dock's left edge as a grab target. Inside the module (not overhanging it) so the module's overflow clipping — which the rounded corners need — can't eat it. */ .dock-grip { @@ -1132,7 +1128,6 @@ html[data-appearance-pointer-cursors="false"] touch-action: none; user-select: none; } - .composer-grip:focus-visible, .dock-grip:focus-visible, .rail-grip:focus-visible { outline: 2px solid var(--foreground); diff --git a/apps/desktop/tests/composerGeometryContract.test.ts b/apps/desktop/tests/composerGeometryContract.test.ts index 6dda5f77..f121c32e 100644 --- a/apps/desktop/tests/composerGeometryContract.test.ts +++ b/apps/desktop/tests/composerGeometryContract.test.ts @@ -25,6 +25,10 @@ const controlChip = readFileSync( new URL("../src/components/ui/control-chip.tsx", import.meta.url), "utf8", ); +const styles = readFileSync( + new URL("../src/styles.css", import.meta.url), + "utf8", +); describe("composer geometry contract", () => { test("paints the compact composer on the same DOM card as its interactive content", () => { expect(composer).not.toContain("function ComposerLiquidSurface"); @@ -65,6 +69,16 @@ describe("composer geometry contract", () => { 'className="size-8 shrink-0 rounded-(--ds-radius-control) transition-transform active:scale-90"', ); }); + + test("does not advertise the removed composer resize grip", () => { + expect(composer).not.toContain("composer-grip"); + expect(composer).not.toContain("useResizeHandle"); + expect(composer).toContain("const applied = Math.min(190, maxHeight);"); + expect(styles).not.toContain(".composer-grip"); + expect(app).not.toContain("codetwo.composerHeight"); + expect(app).not.toContain("composerHByPane"); + expect(composer).toContain('aria-label={docMode ? t("composer.collapseLabel") : t("composer.expandLabel")}'); + }); }); describe("composer multitask contract", () => { diff --git a/apps/desktop/tests/environmentPopoverRendered.test.tsx b/apps/desktop/tests/environmentPopoverRendered.test.tsx index d910adda..cbfa9c1e 100644 --- a/apps/desktop/tests/environmentPopoverRendered.test.tsx +++ b/apps/desktop/tests/environmentPopoverRendered.test.tsx @@ -38,9 +38,17 @@ function renderEnvironment(onRefresh = () => {}, preview = null, props = {}) { describe("EnvironmentPopover layout", () => { test("remains mounted in the session header", () => { const appSource = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8"); + const environmentSource = readFileSync( + new URL("../src/environment/EnvironmentPopover.tsx", import.meta.url), + "utf8", + ); expect(appSource).toContain('from "./environment/EnvironmentPopover"'); expect(appSource).toContain(" { diff --git a/apps/desktop/tests/paneTiles.test.tsx b/apps/desktop/tests/paneTiles.test.tsx index 03606145..14b695c9 100644 --- a/apps/desktop/tests/paneTiles.test.tsx +++ b/apps/desktop/tests/paneTiles.test.tsx @@ -72,6 +72,94 @@ describe("PaneTiles", () => { rendered.unmount(); }); + test("animates only newly split panes from their placement edge", async () => { + const pane = (paneId: string) =>
{paneId}
; + let layout = singlePaneLayout("p1"); + const rendered = mount( + {}} + onResizeSplit={() => {}} + />, + ); + await flush(); + + const initialPane = rendered.container.querySelector("[data-pane-id='p1']")!; + expect(initialPane.hasAttribute("data-pane-entrance")).toBe(false); + + layout = splitPane(layout, "p1", "row", "after", "p2"); + rendered.rerender( + {}} + onResizeSplit={() => {}} + />, + ); + await flush(); + + const rightPane = rendered.container.querySelector("[data-pane-id='p2']")!; + expect(rightPane.getAttribute("data-pane-entrance")).toBe("right"); + expect(rightPane.classList.contains("pane-tile-enter-right")).toBe(true); + expect(initialPane.classList.contains("pane-tile-enter")).toBe(false); + + rendered.rerender( + {}} + onResizeSplit={() => {}} + />, + ); + await flush(); + expect(rendered.container.querySelector("[data-pane-id='p2']")).toBe(rightPane); + expect(rightPane.classList.contains("pane-tile-enter-right")).toBe(true); + + layout = splitPane(layout, "p2", "col", "after", "p3"); + rendered.rerender( + {}} + onResizeSplit={() => {}} + />, + ); + await flush(); + const bottomPane = rendered.container.querySelector("[data-pane-id='p3']")!; + expect(bottomPane.getAttribute("data-pane-entrance")).toBe("bottom"); + expect(bottomPane.classList.contains("pane-tile-enter-bottom")).toBe(true); + + layout = splitPane(layout, "p1", "row", "before", "p4"); + rendered.rerender( + {}} + onResizeSplit={() => {}} + />, + ); + await flush(); + expect( + rendered.container.querySelector("[data-pane-id='p4']")?.getAttribute("data-pane-entrance"), + ).toBe("left"); + + layout = splitPane(layout, "p1", "col", "before", "p5"); + rendered.rerender( + {}} + onResizeSplit={() => {}} + />, + ); + await flush(); + expect( + rendered.container.querySelector("[data-pane-id='p5']")?.getAttribute("data-pane-entrance"), + ).toBe("top"); + rendered.unmount(); + }); + test("renders a resize handle per split", async () => { const rendered = mount( { rendered.unmount(); }); + test("combines Provider and model selection into one T3-style picker", async () => { + activateDom(); + const providers = [ + { + id: "codex", + display_name: "OpenAI Codex", + available: true, + enabled: true, + models: [ + { id: "gpt-5.6-sol", name: "GPT-5.6-Sol", description: "Frontier coding" }, + { id: "gpt-5.6-terra", name: "GPT-5.6-Terra", description: "Balanced coding" }, + ], + }, + { + id: "grok", + display_name: "Grok", + available: true, + enabled: true, + models: [{ id: "grok-4.6", name: "Grok 4.6", description: "Fast reasoning" }], + }, + ]; + const providerChanges = []; + const modelChanges = []; + const rendered = mount( + + providerChanges.push(provider), + })} + models={providers[0].models} + currentModel="gpt-5.6-sol" + defaultModel="gpt-5.6-sol" + onModel={(model) => modelChanges.push(model)} + configOptions={[]} + onConfigOption={() => {}} + /> + , + ); + + const controls = rendered.container.querySelector("[data-session-controls]"); + expect(controls?.querySelector('button[aria-label^="Provider:"]')).toBeNull(); + const trigger = controls?.querySelector('button[title="Model"]'); + expect(trigger?.textContent).toContain("GPT-5.6-Sol"); + if (!trigger) throw new Error("combined Provider/model trigger did not render"); + click(trigger); + await flush(); + + const picker = dom.document.body.querySelector("[data-provider-model-picker]"); + expect(picker).toBeTruthy(); + expect(picker?.querySelector("[data-provider-rail]")).toBeTruthy(); + expect(picker?.querySelector('input[aria-label="Search models"]')).toBeTruthy(); + const grok = picker?.querySelector('button[aria-label="Grok"]'); + if (!grok) throw new Error("Grok Provider rail item did not render"); + click(grok); + await flush(); + + expect(providerChanges).toEqual(["grok"]); + const grokModel = Array.from( + picker?.querySelectorAll('[data-model-picker-row] [data-slot="selectable-row"]') ?? [], + ).find((row) => row.textContent?.includes("Grok 4.6")); + if (!grokModel) throw new Error("Grok model did not render after switching Provider"); + click(grokModel); + await flush(); + expect(modelChanges).toEqual(["grok-4.6"]); + + rendered.unmount(); + }); + test("does not duplicate the worktree control when the checkout bar owns it", async () => { activateDom(); const rendered = mount( diff --git a/apps/desktop/tests/sessionRailRendered.test.tsx b/apps/desktop/tests/sessionRailRendered.test.tsx index e40dfdc7..03d85f68 100644 --- a/apps/desktop/tests/sessionRailRendered.test.tsx +++ b/apps/desktop/tests/sessionRailRendered.test.tsx @@ -3,6 +3,16 @@ import { afterEach, describe, expect, test } from "bun:test"; import { activateDom, click, dom, mount, restoreDom, waitFor } from "./domTestHarness"; activateDom(); +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; +globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; const { I18nProvider } = await import("../src/i18n"); const { SessionRail } = await import("../src/sidebar/SessionRail"); const { SIDEBAR_SECTIONS_STORAGE_KEY } = await import("../src/sidebar/sidebarSections"); @@ -101,25 +111,6 @@ function renderRail(overrides = {}) { ); } -function dragAndDrop(source: Element, target: Element) { - const values = new Map(); - const dataTransfer = { - effectAllowed: "none", - dropEffect: "none", - setData: (type: string, value: string) => values.set(type, value), - getData: (type: string) => values.get(type) ?? "", - }; - const dispatch = (node: Element, type: string) => { - const event = new dom.window.Event(type, { bubbles: true, cancelable: true }); - Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); - node.dispatchEvent(event); - }; - dispatch(source, "dragstart"); - dispatch(target, "dragover"); - dispatch(target, "drop"); - dispatch(source, "dragend"); -} - describe("SessionRail row layout", () => { test("renders external resource Sections in the same flat scroll flow", () => { activateDom(); @@ -262,7 +253,25 @@ describe("SessionRail row layout", () => { view.unmount(); }); - test("applies direct drag ordering to Tasks and Projects", async () => { + test("keeps Project rows collapsible without a trailing disclosure icon", async () => { + activateDom(); + const view = renderRail(); + const toggle = view.container.querySelector('[data-project-toggle="/tmp/repo"]'); + + expect(toggle?.querySelectorAll("svg")).toHaveLength(1); + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + expect(view.container.querySelector('[data-project-content="/tmp/repo"]')).toBeTruthy(); + + click(toggle); + await waitFor(() => { + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + expect(view.container.querySelector('[data-project-content="/tmp/repo"]')).toBeNull(); + }); + + view.unmount(); + }); + + test("exposes dnd-kit keyboard guidance without native HTML5 drag attributes", async () => { activateDom(); const first = { ...session("first", "First task"), last_active_at: 300 }; const second = { ...session("second", "Second task"), last_active_at: 200 }; @@ -276,28 +285,27 @@ describe("SessionRail row layout", () => { previews: {}, }); - dragAndDrop( - view.container.querySelector('[data-session-id="second"] [data-session-drag-handle]'), - view.container.querySelector('[data-session-id="first"]'), + const secondButton = view.container.querySelector( + '[data-session-id="second"] [data-session-select]', ); - await waitFor(() => { - expect(Array.from(view.container.querySelectorAll( - '[data-project-content="/tmp/repo"] [data-session-id]', - )).map((row) => row.getAttribute("data-session-id"))).toEqual(["second", "first"]); - }); + if (!secondButton) throw new Error("missing Task drag fixture"); - dragAndDrop( - view.container.querySelector('[data-project-drag-handle="/tmp/repo"]'), - view.container.querySelector('[data-project-header="/tmp/other"]'), + const repoButton = view.container.querySelector( + '[data-project-toggle="/tmp/repo"]', ); + if (!repoButton) throw new Error("missing Project drag fixture"); + await waitFor(() => { - expect(Array.from(view.container.querySelectorAll('[data-project-list="root"] > [data-project-group]')) - .map((group) => group.getAttribute("data-project-group"))) - .toEqual(["/tmp/repo", "/tmp/other"]); + expect(repoButton.getAttribute("aria-roledescription")).toBe("draggable"); + expect(secondButton.getAttribute("aria-roledescription")).toBe("draggable"); }); - expect(view.container.querySelector( - '[data-session-id="second"] [data-session-drag-handle]', - )?.getAttribute("draggable")).toBe("true"); + expect(repoButton.getAttribute("draggable")).toBeNull(); + expect(secondButton.getAttribute("draggable")).toBeNull(); + expect(view.container.querySelector("[draggable=true]")).toBeNull(); + const description = dom.document.getElementById( + repoButton.getAttribute("aria-describedby") ?? "", + ); + expect(description?.textContent).toContain("use the arrow keys"); expect(view.container.querySelector( '[data-session-id="second"] [data-session-drag-handle] svg', )?.getAttribute("class")).toContain("pointer-events-none"); @@ -390,6 +398,18 @@ describe("SessionRail row layout", () => { view.unmount(); }); + test("lets the search launcher stretch between equal rail insets without overflowing", () => { + activateDom(); + const view = renderRail(); + const search = view.container.querySelector("[data-rail-search]"); + + expect(search?.classList.contains("mx-2")).toBe(true); + expect(search?.classList.contains("w-auto")).toBe(true); + expect(search?.classList.contains("w-full")).toBe(false); + + view.unmount(); + }); + test("groups primary features into Codex-aligned labeled navigation rows", () => { activateDom(); const opened = []; @@ -592,20 +612,21 @@ describe("SessionRail row layout", () => { view.unmount(); }); - test("shows a recent conversation between the title and workspace only when it is useful", () => { + test("shows a recent AI reply between the title and workspace when it is useful", () => { activateDom(); const view = renderRail(); const punctuation = view.container.querySelector('[data-session-id="punctuation"]'); const meaningful = view.container.querySelector('[data-session-id="meaningful"]'); - expect(punctuation?.querySelectorAll("[data-session-line]")).toHaveLength(2); + expect(punctuation?.querySelectorAll("[data-session-line]")).toHaveLength(3); expect(punctuation?.querySelector('[data-session-line="preview"]')).toBeNull(); + expect(punctuation?.querySelector("[data-session-preview]")).toBeNull(); expect(punctuation?.getAttribute("title")).toBeNull(); expect(meaningful?.querySelectorAll("[data-session-line]")).toHaveLength(3); - expect(meaningful?.querySelector('[data-session-line="preview"]')?.textContent) + expect(meaningful?.querySelector("[data-session-preview]")?.textContent) .toBe("A useful preview"); - expect(meaningful?.querySelector('[data-session-line="preview"]')?.className) + expect(meaningful?.querySelector("[data-session-preview]")?.className) .toContain("truncate"); expect(meaningful?.querySelector("[data-session-select]")?.getAttribute("aria-describedby")) .toBe("session-preview-meaningful"); @@ -618,7 +639,9 @@ describe("SessionRail row layout", () => { expect(workspace?.firstElementChild?.textContent).toBe(""); expect(workspace?.querySelector('[data-session-checkout-kind="checkout"]')).toBeTruthy(); expect(workspace?.querySelector("svg")).toBeTruthy(); - expect(row?.querySelector('[data-session-line="provider"]')).toBeNull(); + expect(row?.querySelector('[data-session-line="summary"]')).toBeTruthy(); + expect(row?.querySelector('[data-session-provider="codex"] svg')).toBeTruthy(); + expect(row?.querySelector("[data-session-age]")).toBeTruthy(); expect(row?.querySelector("[data-session-status]")).toBeNull(); expect(row?.querySelector("[data-session-actions]")?.className).toContain("hidden"); expect(row?.querySelector("[data-session-actions]")?.className).toContain("group-hover:flex"); @@ -627,12 +650,12 @@ describe("SessionRail row layout", () => { const meaningfulLines = Array.from(meaningful?.querySelectorAll("[data-session-line]") ?? []) .map((line) => line.getAttribute("data-session-line")); - expect(meaningfulLines).toEqual(["title", "preview", "workspace"]); + expect(meaningfulLines).toEqual(["title", "summary", "workspace"]); view.unmount(); }); - test("keeps provider identity accessible without adding provider branding to the row", () => { + test("keeps provider identity accessible and shows its compact mark", () => { activateDom(); const openCodeSession = { ...session("opencode", "OpenCode task"), provider: "opencode2" }; const view = renderRail({ @@ -643,10 +666,40 @@ describe("SessionRail row layout", () => { }); const row = view.container.querySelector('[data-session-id="opencode"]'); - expect(row?.textContent).not.toContain("OpenCode 2 (Beta)"); + expect(row?.querySelector('[data-session-provider="opencode2"]')?.getAttribute("title")) + .toBe("OpenCode 2 (Beta)"); + expect(row?.querySelector('[data-session-provider="opencode2"] svg')?.getAttribute("viewBox")) + .toBe("96 96 320 320"); expect(row?.querySelector("[data-session-select]")?.getAttribute("aria-label")) .toContain("OpenCode 2 (Beta)"); - expect(row?.querySelectorAll("[data-session-line]")).toHaveLength(2); + expect(row?.querySelectorAll("[data-session-line]")).toHaveLength(3); + + view.unmount(); + }); + + test("shows the latest AI reply, Provider mark, and relative age on the second line", () => { + activateDom(); + const recent = { + ...session("recent", "Recent task"), + provider: "opencode2", + created_at: Date.now() - 5 * 60_000, + last_active_at: Date.now() - 5 * 60_000, + }; + const view = renderRail({ + sessions: [recent], + activeSession: recent.id, + previews: { recent: "The newest AI reply" }, + displayProvider: () => "OpenCode 2 (Beta)", + }); + const row = view.container.querySelector('[data-session-id="recent"]'); + const lines = Array.from(row?.querySelectorAll("[data-session-line]") ?? []); + const summary = row?.querySelector('[data-session-line="summary"]'); + + expect(lines[0]?.getAttribute("data-session-line")).toBe("title"); + expect(lines[1]?.getAttribute("data-session-line")).toBe("summary"); + expect(summary?.textContent).toContain("The newest AI reply"); + expect(summary?.querySelector("svg")?.getAttribute("viewBox")).toBe("96 96 320 320"); + expect(summary?.querySelector("time")?.textContent).toBe("5m"); view.unmount(); }); @@ -714,18 +767,20 @@ describe("SessionRail row layout", () => { view.unmount(); }); - test("omits previews that merely restate the session title", () => { + test("omits punctuation-only previews without dropping Provider and age metadata", () => { activateDom(); const view = renderRail({ sessions: [session("echo", "Paste support")], - previews: { echo: "Paste support" }, + previews: { echo: " · " }, activeSession: "echo", }); const row = view.container.querySelector('[data-session-id="echo"]'); - expect(row?.querySelectorAll("[data-session-line]")).toHaveLength(2); + expect(row?.querySelectorAll("[data-session-line]")).toHaveLength(3); expect(row?.querySelector('[data-session-line="preview"]')).toBeNull(); expect(row?.querySelector("#session-preview-echo")).toBeNull(); + expect(row?.querySelector('[data-session-provider="codex"] svg')).toBeTruthy(); + expect(row?.querySelector("[data-session-age]")).toBeTruthy(); expect(row?.querySelector("[data-session-select]")?.getAttribute("aria-describedby")).toBeNull(); expect(row?.getAttribute("title")).toBeNull(); diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs index af43ff1a..a1c5a9f0 100644 --- a/crates/core/src/store.rs +++ b/crates/core/src/store.rs @@ -1931,11 +1931,12 @@ impl Store { Ok(()) } - /// The most recent text in each session, for the rail's preview line. + /// The most recent Agent text in each session, for the rail's preview line. /// /// One query for every session rather than one per row: the rail redraws on every event, and a /// query per visible session would put the transcript table in the hot path of streaming. - /// Non-text parts (tool calls, plans) are skipped — "ran a command" is not a conversation. + /// User prompts and non-text parts (tool calls, plans) are skipped — the preview answers + /// "what did the AI say?", rather than echoing the Task title or the user's latest question. pub fn last_texts(&self) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( @@ -1944,7 +1945,8 @@ impl Store { WHERE p.seq = ( SELECT MAX(q.seq) FROM parts q WHERE q.session_id = p.session_id - AND json_extract(q.part_json,'$.kind') IN ('text','prompt') + AND q.role='\"agent\"' + AND json_extract(q.part_json,'$.kind')='text' )", )?; let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; @@ -4220,7 +4222,7 @@ mod tests { } #[test] - fn last_texts_returns_the_newest_text_per_session() { + fn last_texts_returns_the_newest_agent_text_per_session() { let store = Store::open_in_memory().unwrap(); let a = Session::new(ProviderId::Grok, "/a"); store.upsert_session(&a).unwrap(); @@ -4243,6 +4245,17 @@ mod tests { }, ) .unwrap(); + // A newer user prompt must not replace the latest AI reply in the sidebar. + store + .append_part( + &a.id, + Role::User, + &Part::Prompt { + text: "latest user question".into(), + display: "latest user question".into(), + }, + ) + .unwrap(); // A tool call lands last, but "ran a command" is not a conversation preview. store .append_part( @@ -4262,7 +4275,8 @@ mod tests { let previews = store.last_texts().unwrap(); assert_eq!(previews.len(), 1); assert_eq!(previews[0].0, a.id); - // Whitespace is flattened so a multi-line answer stays one line in the rail. + // Whitespace is flattened so a multi-line answer stays one line in the rail, and the + // later user prompt does not replace the newest AI reply. assert_eq!(previews[0].1, "second answer"); } @@ -4389,6 +4403,15 @@ mod tests { }, ) .unwrap(); + store + .append_part( + &durable.id, + Role::Agent, + &Part::Text { + text: "durable preview".into(), + }, + ) + .unwrap(); for index in 0..3 { store .append_part( @@ -4400,6 +4423,15 @@ mod tests { }, ) .unwrap(); + store + .append_part( + &transient.id, + Role::Agent, + &Part::Text { + text: "transient preview".into(), + }, + ) + .unwrap(); } let listed = store.list_sessions().unwrap(); diff --git a/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md b/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md new file mode 100644 index 00000000..6500fa5f --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md @@ -0,0 +1,103 @@ +--- +id: change-2026-08-31-align-environment-popover +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with screenshot asking to adjust the environment popover position +inputs: EnvironmentPopover placement props and the shared Base UI Popover collision behavior +outputs: environment popover opens to the right from the header trigger +scope: apps/desktop/src/environment/EnvironmentPopover.tsx, apps/desktop/tests/environmentPopoverRendered.test.tsx, docs/sdlc/changes/2026-08-31-align-environment-popover +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Align the environment popover + +## Intent + +The user highlighted the Environment popover and asked to move it to the right. The current +end-aligned placement makes the wide popover extend left across the reading surface. Align its +leading edge with the Environment trigger so it opens toward the available space on the right. + +## Spec + +Keep the existing header trigger, vertical offset, popover size, content, and shared Popover +primitive. Change only the horizontal alignment from trailing-edge placement to leading-edge +placement. Retain the primitive's built-in collision handling so the popover shifts back into the +viewport when there is not enough room on the right. + +### Acceptance criteria + +- [x] AC-1: The Environment popover's left edge aligns with the Environment trigger and the + popover opens to its right in a normal desktop window. +- [x] AC-2: The popover remains within the viewport and all existing Environment interactions and + content remain intact. +- [x] AC-3: Focused tests, renderer build, rendered Browser inspection, and repository lifecycle + checks pass. + +## Decision and gates + +The direct user request approves this low-risk placement adjustment. The follow-up `pr` authorizes +PR creation only. Human review remains required before merge; release, deployment, and external +mutation remain unauthorized. + +## Plan + +1. Replace the manual trailing-edge offset with semantic leading-edge alignment. +2. Add a focused regression contract for the Environment popover alignment. +3. Exercise the trigger in an isolated rendered window and measure trigger, popup, and viewport + bounds before completing the standard repository checks. + +Rollback restores the previous Environment popover alignment props. + +## Build + +`EnvironmentPopover` now uses the shared Popover primitive's semantic `start` alignment without a +manual horizontal offset. At normal desktop width, the popup therefore starts at the Environment +trigger and grows rightward. The same primitive retains its viewport collision middleware for +constrained windows. The existing side offset, width, scrolling, content, and interactions are +unchanged. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — isolated Browser rendering measured the Environment trigger at left `885.57 px` + and the popup at left `885.5 px`; the Base UI positioner reported `data-align="start"`. +- AC-2: PASS — the rendered popup occupied `885.5–1173.5 px` inside a `1280 px` viewport. It + retained Settings, Changes, Local, Git state, and Commit or push; clicking Local expanded the + existing Add a project action. +- AC-3: PASS — `bun test tests/environmentPopoverRendered.test.tsx` passed four tests and 39 + expectations; targeted TypeScript and ESLint passed; full `bun test` passed 797 tests and 3,808 + expectations; and `bun run build:renderer` completed lint, TypeScript, and the Vite production + build. `bun test script/verify/checks.test.ts`, `bun script/verify/docs.ts`, + `bun script/verify/sdlc.ts`, and `bun script/verify/sdlc.ts --worktree` all passed. Rendered DOM, + interaction, geometry measurement, and screenshot inspection passed. + +Residual risk: collision behavior remains owned by the existing Base UI Popover primitive; the +renderer-only QA verified a 1280 px dark desktop viewport and did not restart the user's existing +Core-backed desktop process. No Core, persistence, or protocol path changed. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +## Feedback + +The screenshot and right-pointing annotation are the accepted scope indicator; no post-change +feedback exists yet. diff --git a/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md b/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md new file mode 100644 index 00000000..88a3e1c4 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md @@ -0,0 +1,101 @@ +--- +id: change-2026-08-31-align-sidebar-search-shortcut +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with screenshot highlighting the sidebar search shortcut alignment +inputs: SessionRail search launcher width and inset behavior +outputs: evenly inset search launcher with an aligned shortcut badge +scope: apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Align the sidebar search shortcut + +## Intent + +The user highlighted the `⌘K` badge in the sidebar search launcher and requested an alignment fix. +The launcher currently combines the row Button's full width with horizontal margins, so its right +edge reaches the rail divider instead of matching the left inset. + +## Spec + +Keep the existing search icon, label, shortcut, click behavior, height, and visual treatment. Make +the search launcher consume the available rail width after both horizontal margins so its left and +right edges use equal insets. The shortcut remains vertically centered by the existing flex row. + +### Acceptance criteria + +- [x] AC-1: The search launcher has equal left and right insets inside the sidebar. +- [x] AC-2: The `⌘K` badge remains vertically centered and fully contained in the launcher. +- [x] AC-3: Search activation, titlebar controls, and feature rows remain unchanged. +- [x] AC-4: Focused tests, renderer build, rendered Browser geometry, and repository lifecycle + checks pass. + +## Decision and gates + +The direct user request approves this low-risk layout correction. The follow-up `pr` authorizes PR +creation only. Human review remains required before merge; release, deployment, and external +mutation remain unauthorized. + +## Plan + +1. Lock the width-overflow symptom with a focused SessionRail regression assertion. +2. Override the row Button's full-width sizing only for the search launcher. +3. Measure both horizontal insets and shortcut containment in an isolated rendered sidebar. + +Rollback restores the previous search launcher width class. + +## Build + +The search launcher now overrides the shared row Button's `w-full` with `w-auto`. As a stretched +flex item, its automatic width accounts for both `mx-2` margins instead of adding those margins to +a full-width row. No shortcut, typography, height, interaction, or shared Button styling changed. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — the pre-fix `bun test tests/sessionRailRendered.test.tsx --test-name-pattern + "equal rail insets"` failed because the launcher retained `w-full`; after the fix it passed. + Browser geometry at 1280 x 720 measured equal `8 px` left and right rail insets. +- AC-2: PASS — Browser `playwright.evaluate(getBoundingClientRect)` measured the shortcut's + vertical center delta from the launcher at `0 px` and confirmed all four shortcut bounds + remained inside the launcher. +- AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx` passed 26 tests and 252 expectations; + clicking the rendered launcher opened the Command palette and Browser console errors/warnings + remained empty. +- AC-4: PASS — full `bun test` and `bun run build:renderer` passed. The renderer build completed + lint, TypeScript, and the Vite production build. Browser page identity, non-blank DOM, overlay + absence, console health, geometry, interaction, and 1280 x 720 screenshot inspection passed. + `bun test script/verify/checks.test.ts`, `bun script/verify/docs.ts`, + `bun script/verify/sdlc.ts`, and `bun script/verify/sdlc.ts --worktree` passed. + +Residual risk: rendered QA used an isolated renderer on port 1421 and did not start or interfere +with the user's Core-backed desktop process. The correction is width-independent because the +browser validates the flex inset contract at the current 290 px rail width; no Core, persistence, +or protocol path changed. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +## Feedback + +The screenshot is the accepted symptom and scope indicator; no post-change feedback exists yet. diff --git a/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md b/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md new file mode 100644 index 00000000..13085912 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md @@ -0,0 +1,114 @@ +--- +id: change-2026-08-31-animate-pane-splits +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with screenshot of the split-right and split-down menu actions +inputs: PaneTiles new-leaf rendering and the repository semantic motion contract +outputs: direction-aware entrance animation for newly split panes +scope: apps/desktop/src/session/PaneTiles.tsx, apps/desktop/src/styles.css, apps/desktop/tests/paneTiles.test.tsx, docs/sdlc/changes/2026-08-31-animate-pane-splits +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Animate new pane splits + +## Intent + +The user highlighted the Split right and Split down actions and requested animation. The menu +already uses the shared layer entrance and hover feedback, so the missing feedback is the visible +result: a newly created pane currently appears instantly. Add restrained direction-aware entrance +motion to the new pane without changing menu geometry, pane layout, focus, divider behavior, or +editor lifetime. + +## Spec + +A pane created to the right enters from the right; a pane created below enters from below. The same +mapping supports left/top split edges for existing non-menu entry points. Use the repository's +220 ms semantic motion duration and standard entrance curve, with a short 12 px translation and +opacity only. The initial single pane must not animate, stable panes must not replay animation on +rerender, and the global Reduced Motion contract must collapse the effect. + +### Acceptance criteria + +- [x] AC-1: Choosing Split right or Split down gives the newly created pane a matching directional + entrance while existing panes stay stable. +- [x] AC-2: The initial pane does not animate, and a pane does not replay its entrance during normal + rerenders or divider resizing. +- [x] AC-3: The animation uses semantic timing/easing, supports every split edge, and collapses + under Reduced Motion. +- [x] AC-4: Focused/full tests, renderer build, rendered Browser inspection, and repository + lifecycle checks pass. + +## Decision and gates + +The direct user request approves this low-risk visual feedback. The follow-up `pr` authorizes PR +creation only. Human review remains required before merge; release, deployment, and external +mutation remain unauthorized. + +## Plan + +1. Identify newly mounted pane IDs and their parent split edge without adding React state. +2. Apply one stable direction class to the new pane and define a semantic CSS entrance. +3. Lock initial/new/rerender behavior in PaneTiles tests and verify both split commands in an + isolated rendered window, including Reduced Motion. + +Rollback removes the pane entrance bookkeeping, classes, and keyframes. + +## Build + +`PaneTiles` now remembers pane IDs already present at its initial render and records a stable parent +split edge only when a new leaf appears. The initial pane therefore has no entrance class, while a +new pane receives `left`, `right`, `top`, or `bottom` without adding React state or a second render. +The class remains stable on the same keyed pane node, so ordinary state updates and divider moves +cannot replay the animation. + +The CSS entrance uses opacity plus a 12 px directional `translate3d`, the existing 220 ms semantic +motion duration, and the standard entrance curve. The repository-wide Reduced Motion override +continues to collapse it. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `Browser Split right / Split down interaction` created `pane-2` with + `data-pane-entrance="right"` and `pane-3` with `data-pane-entrance="bottom"`; both rendered + `animation-name: pane-tile-enter`. +- AC-2: PASS — `bun test tests/paneTiles.test.tsx tests/paneChrome.test.tsx` verifies that the + initial pane has no entrance marker, existing pane DOM identity is preserved across rerender, + and only newly added leaves receive an entrance class. +- AC-3: PASS — `Browser computed-style inspection` measured `0.22s` and + `cubic-bezier(0.16, 1, 0.3, 1)` for both menu actions. The focused test covers left, right, top, + and bottom. Setting Appearance → Reduce motion → On reduced a fresh split to `1e-05s`. +- AC-4: PASS — the focused pane suites passed nine tests and 49 expectations; `bunx tsc --noEmit` + and targeted ESLint passed; full `bun test` passed 797 tests and 3,816 expectations; and + `bun run build:renderer` completed lint, TypeScript, and the Vite production build. Browser page + identity, meaningful content, menu interaction, screenshot inspection, and console checks passed + with no relevant warnings or errors. + +Residual risk: a static screenshot proves the final tiled geometry while computed styles and live +interaction prove the short entrance itself; the renderer-only QA did not restart the user's +existing Core-backed desktop process. No Core protocol or persistence path changed. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +## Feedback + +The screenshot is the accepted scope indicator; no post-change feedback exists yet. diff --git a/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md b/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md new file mode 100644 index 00000000..ca3cff06 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md @@ -0,0 +1,107 @@ +--- +id: change-2026-08-31-remove-composer-resize-grip +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with screenshot highlighting the non-working composer resize grip +inputs: current Composer grip, resize state, styles, and translated label +outputs: composer without the unusable top resize affordance or its dormant feature wiring +scope: apps/desktop/src/App.tsx, apps/desktop/src/session/Composer.tsx, apps/desktop/src/styles.css, apps/desktop/src/i18n/strings.ts, apps/desktop/tests/composerGeometryContract.test.ts, docs/sdlc/changes/2026-08-31-remove-composer-resize-grip +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Remove the composer resize grip + +## Intent + +The user identified the small horizontal grip at the top center of the prompt Composer and asked +to remove it because it cannot actually be dragged in the product. Remove the misleading control +and its resize-only state rather than leaving an invisible or dormant interaction. Preserve the +Composer card, its bounded compact height, the explicit full-page toggle, editor content, and all +send controls. The follow-up `pr` authorizes PR creation for this verified scope only; a broader +Composer redesign, merge, or release remains unauthorized. + +## Spec + +The compact Composer has no top resize grip, resize cursor, drag handler, double-click target, or +resize accessibility label. Its document area retains the current 190 px default maximum and +continues to clamp against the available column height. Full-page mode remains available through +the existing explicit expand/collapse button. Remove the persisted and per-pane height mutation +wiring that only served the deleted grip. + +### Acceptance criteria + +- [x] AC-1: The highlighted top-center grip is absent from the rendered compact Composer without + leaving extra top spacing or changing the card radius. +- [x] AC-2: Composer source, styles, translations, and App wiring contain no grip resize feature, + while the explicit full-page control and bounded compact editor remain. +- [x] AC-3: Focused tests, full desktop tests, renderer build, rendered Browser inspection, and + repository lifecycle checks pass. + +## Decision and gates + +The user's direct request accepts this low-risk deletion, and the follow-up `pr` authorizes PR +creation. Human review remains required before merge. No release, deployment, or external mutation +is authorized. + +## Plan + +1. Delete the grip element, resize hook, styles, label, and App-owned height mutation state. +2. Retain a fixed compact maximum with the existing available-column clamp and add a source + contract preventing the affordance from returning. +3. Verify the rendered compact and full-page controls in an isolated renderer, then run repository + tests, build, and lifecycle Gates. + +Rollback restores the grip and its prior resize state. + +## Build + +Removed the `composer-grip` element, `useResizeHandle` binding, grip styles, focus selector, and +English/Chinese grip label. `App` no longer persists or maintains per-pane composer resize state, +and `Composer` no longer accepts height mutation props. The compact editor keeps the prior 190 px +default maximum and its available-column clamp; the existing explicit expand/collapse control is +unchanged. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `Browser rendered compact Composer check` found zero `.composer-grip` elements. + The first document child begins at the exact card top, proving the deleted 11 px grip did not + leave a spacer, and computed card radius remains `24px` at 1280x720. +- AC-2: PASS — `rg -n 'composer-grip|composer\.grip|codetwo\.composerHeight|composerHByPane' + apps/desktop/src` returned no matches. `bun test tests/composerGeometryContract.test.ts` passed + four tests and 34 expectations, including the fixed 190 px bound and explicit full-page button. +- AC-3: PASS — `bun test` passed 796 tests and 3,794 expectations; `bunx tsc --noEmit` passed; + `bun run build:renderer` completed lint, TypeScript, and the Vite production build. Browser page + identity, meaningful content, compact/full-page interaction, framework-overlay inspection, + screenshot inspection, and console checks passed with no relevant warnings or errors. + +Residual risk: verification used the isolated renderer rather than restarting the user's existing +Core-backed desktop process. The changed surface is renderer-only, and no Core protocol or user +data path changed. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +## Feedback + +The highlighted screenshot and statement that the grip cannot be dragged are the direct defect +feedback for this change. diff --git a/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md b/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md new file mode 100644 index 00000000..0902d9be --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md @@ -0,0 +1,104 @@ +--- +id: change-2026-08-31-remove-project-disclosure-icon +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with screenshot marking the Project-row disclosure icon +inputs: current desktop SessionRail Project header and screenshot feedback +outputs: Project rows without a trailing disclosure icon while retaining whole-row folding +scope: apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Remove the Project disclosure icon + +## Intent + +The user marked the trailing arrow on the `codeTwo` Project row and requested that the icon not be +shown. The affected surface is every Project header in the desktop sidebar. The Project name, +folder icon, actions, drag behavior, ordering, and whole-row expand/collapse interaction must remain +unchanged. The follow-up `pr` authorizes PR creation for this verified scope only; a broader sidebar +redesign, merge, release, or deployment remains unauthorized. + +## Spec + +Project headers render the folder icon and Project name without a trailing disclosure arrow. The +existing Project header remains the accessible collapsible trigger, continues to expose its +expanded state, and still reveals or hides the Project's Tasks when activated. Section, archive, +menu, and unrelated chevron icons are outside this change. + +### Acceptance criteria + +- [x] AC-1: Every Project header omits the trailing disclosure icon while retaining its folder icon. +- [x] AC-2: Activating the Project header still toggles its expanded state and Task content. +- [x] AC-3: Focused rendered tests, renderer checks, lifecycle checks, and a real rendered sidebar + inspection pass. + +## Decision and gates + +The direct user request accepts this low-risk Intent and visible UI direction. Human review remains +required before merge. No release or production action is authorized. + +## Plan + +1. Remove the decorative disclosure icon from the shared Project-header renderer. +2. Add a rendered regression covering both the icon count and retained collapse behavior. +3. Run focused checks, repository lifecycle checks, and inspect the rendered sidebar. + +Rollback reverts this bundle and restores the disclosure icon. + +## Build + +The shared Project header no longer renders its trailing disclosure arrow. A rendered regression +asserts that its only SVG is the folder and that activating the same header still folds its Task +content. No material deviation from the Plan was required. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `bun test tests/sessionRailRendered.test.tsx` passed 24 tests and the 954 by 858 + in-app Browser inspection showed `codeTwo`, `open-mole`, and `MacOS` with one folder SVG each and + no trailing disclosure arrow. +- AC-2: PASS — the focused test and Browser interaction changed the `codeTwo` header from + `aria-expanded=true` to `false`, removed its Project content, then restored the expanded content. +- AC-3: PASS — full `bun test` passed 795 tests across 137 files with 3,799 expectations; + `bun run build:renderer`, `bun test script/verify/checks.test.ts`, + `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, and + `bun script/verify/sdlc.ts --worktree` passed; the dark rendered view had no relevant console + error or warning. + +The first focused test attempt failed before loading the suite because this new worktree had no +installed dependencies (`Cannot find module 'react/jsx-dev-runtime'`). `bun install +--frozen-lockfile` restored the lockfile-defined dependencies, after which the same test passed. + +Residual risk: native Core-backed data was not opened because another CodeTwo checkout already +owned the user's live development instance. Visual QA used the isolated renderer on port 1421 with +temporary in-memory Project fixtures that were removed before the final build; the shared Project +component and interaction path were the production implementation. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle to restore the previous Project-header icon. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +Preparing this section does not authorize merge, deployment, release, or production mutation. + +## Feedback + +This change directly records the screenshot feedback. No post-change feedback exists yet. diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md new file mode 100644 index 00000000..6ef6dadf --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md @@ -0,0 +1,127 @@ +--- +id: change-2026-08-31-replace-sidebar-drag-with-dnd-kit +kind: change +schema: 2 +status: verified +risk: medium +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with screenshot showing failed Project sorting and folder placement +inputs: current desktop SessionRail drag behavior and physical pointer-drag reproduction +outputs: library-backed sidebar sorting and Project-to-Section placement +scope: apps/desktop/package.json, apps/desktop/bun.lock, apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/src/sidebar/sidebarDnd.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Replace sidebar drag handling with dnd-kit + +## Intent + +The user reported that physical pointer dragging cannot reorder sidebar items or place a Project in +a user Section, and explicitly requested a mature library instead of another custom implementation. +The reproduced pointer path emitted `pointerdown` but never emitted the native HTML5 `dragstart`, +so the existing `dataTransfer` handlers did not reach the domain move operations. This change +replaces only the sidebar interaction layer; existing Section, Project, Task ordering and +persistence APIs remain authoritative. The follow-up `pr` authorizes PR creation for this verified +scope only; a broader sidebar redesign, merge, release, or deployment remains unauthorized. + +## Spec + +Use `@dnd-kit/react` to provide pointer and keyboard drag input for Section, Project, and Task rows. +Projects can reorder within a root or Section list, move into a Section including an empty Section, +and move back to the root list. Existing Task and Section moves continue to call the current domain +operations. The implementation must not depend on native HTML5 `draggable`, `dragstart`, or +`dataTransfer` handlers. + +### Acceptance criteria + +- [x] AC-1: A physical pointer drag reorders Projects in the root list and persists the new order. +- [x] AC-2: A physical pointer drag moves a Project into a user Section and back to the root list, + including a Section without existing Projects. +- [x] AC-3: Section and Task drag targets continue to map to the existing move operations, with + keyboard drag support supplied by the library. +- [x] AC-4: Focused rendered tests, type checks, renderer build, lifecycle checks, and an isolated + real rendered-window inspection pass. + +## Decision and gates + +The direct user request accepts this medium-risk Intent and the dependency choice. Human review +remains required before merge. No release or production action is authorized. + +## Plan + +1. Add the maintained `@dnd-kit/react` package and a small typed adapter for sidebar items and + drop locations. +2. Replace native drag handlers with `DragDropProvider`, sortable rows, and explicit empty-list + drop zones while retaining the existing state mutation callbacks. +3. Update focused rendered coverage and run type, unit, build, lifecycle, and physical pointer-drag + verification in an isolated renderer. + +Rollback removes the dependency and adapter and restores the previous sidebar drag interaction. + +## Build + +Added the pinned `@dnd-kit/react` dependency and a typed sidebar adapter for sortable rows and +explicit drop zones. `SessionRail` now wraps Section, Project, and Task rows in the library's +provider and preserves the existing domain operations for persisted moves. Native HTML5 +`draggable`, `dragstart`, `dataTransfer`, and `drop` handling was removed. + +Project moves decode the library's final sortable group and index so a same-list sort, a +cross-Section move, and a move back to root all resolve against the actual destination rather than +the optimistic source row. Empty Section and empty root drop zones have distinct IDs and explicit +collision priority, so another item kind cannot overwrite the registered target. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `Browser physical pointer drag` in the isolated rendered desktop shell changed the root + Project order from `open-mole, codeTwo, MacOS` to `open-mole, MacOS, codeTwo`; a hard reload kept + the persisted renderer state. +- AC-2: PASS — physical pointer dragging moved `codeTwo` into the empty `Work` Section, and a + separate pointer drag moved `open-mole` from `Work` back to the root Project list. +- AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx tests/sidebarProjects.test.ts + tests/sidebarSections.test.ts` verifies the rendered DOM exposes dnd-kit's draggable semantics + and keyboard instructions + while containing no native sidebar `[draggable=true]` elements. Section, Project, and Task rows + retain their existing typed move callbacks; the Project and Section domain suites pass. +- AC-4: PASS — focused sidebar suites passed 31 tests and 262 expectations; `bunx tsc --noEmit` + passed; the full desktop suite passed 795 tests and 3,787 expectations; and + `bun run build:renderer` completed lint, TypeScript, and the Vite production build. The isolated + rendered pass covered same-list sorting, placement into an empty Section, return to root, and + the final visual state without starting a second Core process. + +The initial native-HTML5 baseline emitted `pointerdown` but not `dragstart`. During replacement, +the first library pass exposed two integration defects: optimistic same-list sorting reported the +source row as the final target, and Project/Task empty drop zones shared an ID. The final adapter +uses sortable destination metadata and kind-qualified drop-zone IDs; both failure paths were +retested after correction. + +Residual risk: the isolated renderer verified library-provided keyboard semantics and instructions, +but the complete keyboard reorder gesture was not manually exercised. The user's live Core-backed +profile was deliberately not opened because another process owns it; the unchanged persistence +operations are covered by domain tests, and renderer persistence was checked across reload. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle to restore the prior drag interaction. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +Preparing this section does not authorize merge, deployment, release, or production mutation. + +## Feedback + +The screenshot and request are the direct defect feedback for this change. No post-change feedback +exists yet. diff --git a/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md b/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md new file mode 100644 index 00000000..cc593af3 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md @@ -0,0 +1,110 @@ +--- +id: change-2026-08-31-restore-sidebar-session-summary +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with screenshot showing missing session summary metadata in the sidebar +inputs: persisted transcript previews, session provider and activity timestamps, SessionRail row layout +outputs: sidebar session summary line with latest AI reply, provider mark, and compact relative age +scope: crates/core/src/store.rs, apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Restore the sidebar session summary + +## Intent + +The user reported that Task rows no longer show their second line. That line must make a session +scannable by combining the newest AI reply, the Provider mark, and how long ago the Task was last +active. The current renderer instead treats any newest text, including the user's prompt, as a +preview and separately reserves the lower line for workspace provenance. + +## Spec + +The first line remains the Task title and its existing activity/actions. Immediately below it, +render one compact summary line with the monochrome Provider mark, a truncated newest Agent text, +and a compact relative age derived from `last_active_at` with `created_at` fallback. Sessions with +no Agent text retain the line with Provider and age, without inventing reply content. Workspace, +checkout, worktree, and pull-request provenance remain on the following line. + +The Core preview query must select only the newest Agent text. It must not replace an existing AI +reply with a later user prompt. Preserve the existing one-query projection and 160-character bound. + +### Acceptance criteria + +- [x] AC-1: A Task with an AI reply renders it on the second line with the correct Provider mark + and compact relative age. +- [x] AC-2: A later user prompt cannot replace the latest AI reply returned by the preview query; + a Task without an AI reply does not invent one. +- [x] AC-3: Existing title, activity, actions, drag-and-drop, workspace, checkout/worktree, and PR + behavior remains intact. +- [x] AC-4: Focused and full tests, renderer build, rendered Browser inspection, and repository + lifecycle checks pass. + +## Decision and gates + +The direct user request approves this low-risk sidebar correction. The follow-up `pr` authorizes PR +creation only. Human review remains required before merge; release, deployment, and external +mutation remain unauthorized. + +## Plan + +1. Lock the AI-only preview and summary-line structure with red regression tests. +2. Restore the Provider mark and age using existing row data and the shared ProviderIcon. +3. Verify actual dark rendered rows at the sidebar's normal width, then run repository Gates. + +Rollback restores the previous preview query and SessionRail row projection. + +## Build + +The Core preview projection now selects only the newest Agent text, preserving the existing +one-query and bounded-preview behavior. SessionRail renders a fixed summary line with the shared +ProviderIcon, a one-line Agent preview when present, and compact relative activity age driven by +one rail-wide minute ticker. Workspace and Git provenance remain on the following line. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `bun test tests/sessionRailRendered.test.tsx` passed 25 tests and 249 + expectations. Browser inspection of the real SessionRail at 320 px showed Provider marks, + single-line truncated AI replies, and `5m` / `2h` ages on the second line. +- AC-2: PASS — `cargo test -p codetwo-core + last_texts_returns_the_newest_agent_text_per_session` and all 50 Core store tests passed, + including a later user prompt that must not displace the latest Agent reply. +- AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx` retained title, activity, action, + drag, workspace, checkout/worktree, and pull-request assertions; the full `bun test` suite + passed 798 tests and 3,773 expectations. +- AC-4: PASS — full `cargo test -p codetwo-core`, `bun run build:renderer`, TypeScript, focused + ESLint, Browser DOM/geometry inspection, all five verification-script tests, documentation + verification, and both committed and worktree SDLC checks passed. + +Residual risk: the rendered inspection used an isolated fixture-backed renderer so it would not +start or interfere with the user's live Core. Existing persisted sessions require the updated Core +and renderer to be launched together before the repaired projection appears. The repository-wide +`cargo fmt --all --check` remains blocked by pre-existing formatting drift in unrelated Rust files; +this low-risk UI change did not rewrite those files. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +## Feedback + +The screenshot is the accepted symptom and scope indicator; no post-change feedback exists yet. diff --git a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md new file mode 100644 index 00000000..26f7f9c5 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md @@ -0,0 +1,121 @@ +--- +id: change-2026-08-31-simplify-provider-model-picker +kind: change +schema: 2 +status: verified +risk: medium +owner: codex +approvers: [user] +approved_at: 2026-08-31 +created: 2026-08-31 +updated: 2026-08-31 +source: direct user request with T3 model-picker reference and current CodeTwo screenshot +inputs: current Provider registry, provider-owned model choices, model favorites and preferences +outputs: one Composer model entry with Provider rail, search, and model selection +scope: apps/desktop/src/session/Composer.tsx, apps/desktop/tests/sceneChip.test.tsx, docs/sdlc/changes/2026-08-31-simplify-provider-model-picker +next_trigger: PR review; merge and release remain pending +verification_mode: owner +verified_by: codex +verified_at: 2026-08-31 +--- + +# Simplify Provider and model selection + +## Intent + +The user asked to simplify CodeTwo's separate Provider and model controls using T3's model picker as +the reference. The accepted interaction is one compact trigger showing the Provider mark and current +model, opening one surface where the user can switch Provider, search models, and select a model. + +## Spec + +In the primary Composer controls, remove the separate visible Provider chip. Extend the existing +model picker with a narrow Provider icon rail and keep the searchable model list beside it. Reuse +the current Provider registry, ProviderIcon, model grouping, favorites, hidden-model preferences, +adapter-owned model options, disabled states, and existing callbacks. Provider loading, retry, and +unavailable states remain legible. Selecting a Provider updates the existing Provider state and +keeps the surface open so a model can be selected directly. + +Do not copy T3-only shortcut labels, legacy sections, or new favorite semantics. Keep reasoning +effort independent because it is a separate provider-owned configuration axis. ModelPicker uses in +Side Chat and Project Settings remain model-only and retain their current compact surface. + +### Acceptance criteria + +- [x] AC-1: Primary Composer controls show one Provider/model trigger instead of separate Provider + and model chips. +- [x] AC-2: The unified popup has a Provider icon rail, model search, and a scrollable model list + whose selected/favorite/default states reuse existing behavior. +- [x] AC-3: Switching Provider updates the existing Provider callback and reveals that Provider's + models without closing the popup; selecting a model uses the existing model callback. +- [x] AC-4: Reasoning effort, disabled/running behavior, provider loading/error states, Side Chat, + and Project Settings model-only use remain intact. +- [x] AC-5: Focused/full tests, renderer build, rendered Browser interaction and reference-image + comparison, and repository lifecycle checks pass. + +## Decision and gates + +The direct user request approves this medium-risk interaction simplification. The follow-up `pr` +authorizes PR creation only. Human review remains required before merge; release, deployment, and +external mutation remain unauthorized. + +## Plan + +1. Add a focused SessionControls regression for one trigger and Provider-to-model selection. +2. Extend ModelPicker with an optional Provider rail while preserving model-only callers. +3. Render an isolated realistic provider/model fixture and compare it with the T3 reference. + +Rollback restores the separate ProviderPicker render in SessionControls and removes the optional +Provider rail from ModelPicker. + +## Build + +`SessionControls` now renders one `ModelPicker` entry for Provider and model selection. The +Provider-enabled picker reuses the current registry and callbacks in a compact icon rail, keeps the +searchable model projection and existing row states on the right, and leaves model-only callers on +their original surface. The separate effort selector remains unchanged. + +## Verification + +Verdict: verified. + +The requested T3-style simplification is implemented and the changed behavior is covered by a +regression test plus an isolated rendered interaction check. + +### Acceptance evidence + +- AC-1: PASS — `bun test tests/sceneChip.test.tsx --test-name-pattern "combines Provider and model"` + first failed against the separate Provider button, then passed after `SessionControls` exposed one + Provider/model trigger. +- AC-2: PASS — the built-in Browser at `1280x720` showed a `480px`-wide popup fully inside the + viewport, with a `56px` Provider rail, search field, scrollable model list, and selected/default/ + favorite row states. The saved Browser screenshot was compared with the supplied T3 reference + using image inspection. +- AC-3: PASS — `bun test tests/sceneChip.test.tsx --test-name-pattern "combines Provider and model"` + verifies the Provider callback, in-place model replacement, and model callback. The rendered flow + also filtered for `Terra`, switched to Grok without closing, selected `Grok 4.6`, and updated the + trigger without console errors. +- AC-4: PASS — `bun test tests/reasoningScaleRendered.test.tsx tests/sceneChip.test.tsx` passed 26 + tests and 92 expectations; `bunx tsc --noEmit` and focused ESLint also passed. Model-only picker + tests remain in that passing suite. +- AC-5: PASS — `bun test` and `bun run build:renderer` passed. Repository lifecycle commands are + recorded by the final verification pass below. + +Residual risk: the visual exercise used an isolated renderer fixture rather than starting another +Core process, as required by the repository's single-owner rule. Its `1280x720` viewport is slightly +shorter than the supplied T3 screenshot. T3-only shortcut labels, Legacy models, and global favorite +navigation were intentionally not copied; no backend or provider protocol changed. + +## Review and release + +Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: revert this change bundle. +No release: PR creation is authorized; merge, deployment, and release remain unauthorized. + +## Feedback + +The T3 and CodeTwo screenshots are the accepted reference and current-state evidence; no post-change +feedback exists yet. From 5bd153ad045e3e18caf4674d7591162282967ea7 Mon Sep 17 00:00:00 2001 From: idevlab Date: Mon, 31 Aug 2026 22:51:27 +0800 Subject: [PATCH 2/8] docs(sdlc): link desktop polish PR --- .../changes/2026-08-31-align-environment-popover/change.md | 3 ++- .../changes/2026-08-31-align-sidebar-search-shortcut/change.md | 3 ++- docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md | 3 ++- .../changes/2026-08-31-remove-composer-resize-grip/change.md | 3 ++- .../2026-08-31-remove-project-disclosure-icon/change.md | 3 ++- .../2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md | 3 ++- .../2026-08-31-restore-sidebar-session-summary/change.md | 3 ++- .../2026-08-31-simplify-provider-model-picker/change.md | 3 ++- 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md b/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md index 6500fa5f..8686ad4d 100644 --- a/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md +++ b/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md @@ -90,7 +90,8 @@ Core-backed desktop process. No Core, persistence, or protocol path changed. ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. diff --git a/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md b/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md index 88a3e1c4..f095b6e5 100644 --- a/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md +++ b/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md @@ -89,7 +89,8 @@ or protocol path changed. ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. diff --git a/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md b/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md index 13085912..cf0d8807 100644 --- a/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md +++ b/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md @@ -102,7 +102,8 @@ existing Core-backed desktop process. No Core protocol or persistence path chang ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. diff --git a/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md b/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md index ca3cff06..98c834f4 100644 --- a/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md +++ b/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md @@ -94,7 +94,8 @@ data path changed. ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. diff --git a/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md b/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md index 0902d9be..c9484479 100644 --- a/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md +++ b/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md @@ -90,7 +90,8 @@ component and interaction path were the production implementation. ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md index 6ef6dadf..3b98ea61 100644 --- a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md +++ b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md @@ -112,7 +112,8 @@ operations are covered by domain tests, and renderer persistence was checked acr ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. diff --git a/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md b/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md index cc593af3..1252142b 100644 --- a/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md +++ b/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md @@ -98,7 +98,8 @@ this low-risk UI change did not rewrite those files. ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. diff --git a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md index 26f7f9c5..d14474ef 100644 --- a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md +++ b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md @@ -108,7 +108,8 @@ navigation were intentionally not copied; no backend or provider protocol change ## Review and release -Approval: PR creation authorized by the user's follow-up `pr`; merge pending human review. +Approval: [PR #208](https://github.com/IchenDEV/codeTwo/pull/208) created by user authorization; +merge pending human review. Release target: none. Release identity: not applicable until released. Smoke evidence: not applicable until released. From 7cd785e8c7526da88cdbb8da6ab839ab01ab0990 Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 00:44:06 +0800 Subject: [PATCH 3/8] fix: close desktop UI review gaps --- apps/desktop/src/App.tsx | 21 ++- apps/desktop/src/components/ui/drag-drop.tsx | 31 ++++ apps/desktop/src/session/Composer.tsx | 130 +++-------------- apps/desktop/src/session/config.ts | 21 ++- apps/desktop/src/sidebar/SessionRail.tsx | 134 ++++++++++++------ apps/desktop/src/sidebar/sidebarDnd.tsx | 91 ++++++++++-- .../tests/checkoutPickerRendered.test.tsx | 1 + .../tests/providerModelTransition.test.ts | 52 +++++++ apps/desktop/tests/sceneChip.test.tsx | 30 ++-- .../tests/sessionRailRendered.test.tsx | 22 +-- apps/desktop/tests/sidebarDnd.test.ts | 57 ++++++++ crates/core/src/store.rs | 69 +++++++-- .../change.md | 16 ++- .../change.md | 11 +- .../2026-08-31-animate-pane-splits/change.md | 13 +- .../change.md | 15 +- .../change.md | 13 +- .../change.md | 68 +++++---- .../evidence/drag-result-dark.png | Bin 0 -> 31519 bytes .../evidence/environment-light.png | Bin 0 -> 36731 bytes .../evidence/model-picker-dark.png | Bin 0 -> 36780 bytes .../evidence/model-picker-light.png | Bin 0 -> 6134 bytes .../evidence/model-picker-narrow-dark.png | Bin 0 -> 17415 bytes .../evidence/pr-review-dark.png | Bin 0 -> 32654 bytes .../evidence/pr-review-narrow-dark.png | Bin 0 -> 14126 bytes .../evidence/split-animation-end-light.png | Bin 0 -> 35016 bytes .../change.md | 35 ++--- .../change.md | 59 +++++--- 28 files changed, 581 insertions(+), 308 deletions(-) create mode 100644 apps/desktop/src/components/ui/drag-drop.tsx create mode 100644 apps/desktop/tests/providerModelTransition.test.ts create mode 100644 apps/desktop/tests/sidebarDnd.test.ts create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/drag-result-dark.png create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/environment-light.png create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-dark.png create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-light.png create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-narrow-dark.png create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-dark.png create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-narrow-dark.png create mode 100644 docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/split-animation-end-light.png diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 193ffa89..0dc4c993 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -251,7 +251,10 @@ import { dirtyKey, isDirty as isFileDirty, markDirty } from "./files/dirty"; import { synchronizeLspRuntimePolicy } from "./lsp/runtimePolicy"; import { configurePluginLanguageServers } from "./lsp/client"; import { quickQuotaProviderFor, quickQuotaSummary } from "./usage/quickQuota"; -import type { SessionConfig } from "./session/config"; +import { + transitionProviderModelSelection, + type SessionConfig, +} from "./session/config"; import { SESSION_MODES, executionPolicyChangeDisabled, @@ -7283,6 +7286,22 @@ export default function App() { setDefaultModel(null); } }, + onProviderModel: (nextProvider, nextModel) => { + transitionProviderModelSelection({ + hasActiveSession: activeSessionRef.current !== null, + createSession, + apply: () => { + providerPinned.current = true; + setProvider(nextProvider); + setModels( + providers.find((candidate) => candidate.id === nextProvider)?.models ?? [], + ); + setCurrentModel(nextModel); + setDefaultModel(null); + setConfigOptions([]); + }, + }); + }, onReloadProviders: () => { void refreshProviders().catch(() => {}); }, diff --git a/apps/desktop/src/components/ui/drag-drop.tsx b/apps/desktop/src/components/ui/drag-drop.tsx new file mode 100644 index 00000000..184ea13e --- /dev/null +++ b/apps/desktop/src/components/ui/drag-drop.tsx @@ -0,0 +1,31 @@ +import type { ComponentProps } from "react"; +import { + DragDropProvider as DndKitProvider, + useDroppable, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, + type UseDroppableInput, +} from "@dnd-kit/react"; +import { + useSortable, + type UseSortableInput, +} from "@dnd-kit/react/sortable"; + +/** Shared boundary for the desktop's mature drag-and-drop interaction library. */ +function DragDropRoot(props: ComponentProps) { + return ; +} + +export { + DragDropRoot, + useDroppable as useDragDropZone, + useSortable as useDragDropSortable, +}; +export type { + DragEndEvent, + DragOverEvent, + DragStartEvent, + UseDroppableInput, + UseSortableInput, +}; diff --git a/apps/desktop/src/session/Composer.tsx b/apps/desktop/src/session/Composer.tsx index c7f9f779..ed018019 100644 --- a/apps/desktop/src/session/Composer.tsx +++ b/apps/desktop/src/session/Composer.tsx @@ -38,7 +38,6 @@ import { ProviderIcon } from "../providers/ProviderIcon"; import { VoiceButton } from "../voice/VoiceButton"; import { fallbackProviders, - providerDisplayName, type ConfigOptionInfo, type AppshotCapture, type GoalCapabilityInfo, @@ -64,6 +63,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useT } from "../i18n"; import { cn } from "@/lib/utils"; @@ -768,107 +768,6 @@ export function WorktreePicker({ config }: { config: SessionConfig }) { ); } -export function ProviderPicker({ config }: { config: SessionConfig }) { - const t = useT(); - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - const registry = config.providers.length > 0 ? config.providers : fallbackProviders(); - // Disabled providers stop being new-session choices. Keep the active one visible so a resumed - // session still identifies the runtime it already owns. - const providers = registry.filter((candidate) => candidate.enabled !== false || candidate.id === config.provider); - const active = providers.find((p) => p.id === config.provider); - const activeLabel = active?.display_name ?? providerDisplayName(config.provider); - const registryReady = config.providersStatus === "ready"; - - useEffect(() => { - const openProviderPicker = () => { - setOpen(true); - window.setTimeout(() => triggerRef.current?.focus(), 0); - }; - window.addEventListener("codetwo-open-provider-picker", openProviderPicker); - return () => window.removeEventListener("codetwo-open-provider-picker", openProviderPicker); - }, []); - - return ( - - - {registryReady && active && !active.available && ( - - )} - - {activeLabel} - - - } - /> - - {t("config.provider")} - {config.providersStatus === "loading" && ( -

- {t("config.providersLoading")} -

- )} - {config.providersStatus === "error" && ( -
- - {t("config.providersLoadFailed")} - - -
- )} - {providers.map((p) => ( - - - {/* The brand mark; dimmed when the CLI isn't installed, like the row's text. */} - - - } - disabled={registryReady && !p.available} - onSelect={() => { - config.onProvider(p.id); - setOpen(false); - }} - /> - ))} -
-
- ); -} - /** * The model this turn will run on: a model chip, and an effort chip when the model comes in * reasoning variants. @@ -941,6 +840,13 @@ export function ModelPicker({ const pickerConfigOptions = browsingCurrentProvider ? configOptions : []; const pickerCurrent = browsingCurrentProvider ? current : null; const pickerDefaultModel = browsingCurrentProvider ? defaultModel : null; + const selectPickerModel = (id: string) => { + if (providerConfig && pickerProvider !== provider) { + providerConfig.onProviderModel(pickerProvider, id); + return; + } + onModel(id); + }; const families = useMemo(() => groupModels(models), [models]); const pickerFamilies = useMemo(() => groupModels(pickerModels), [pickerModels]); const { favorites, toggle: toggleFavorite } = useProviderModelFavorites(pickerProvider); @@ -1012,7 +918,7 @@ export function ModelPicker({ detail: f.variants[0]?.choice.description, isDefault: f.variants.some((v) => v.choice.id === pickerDefaultModel), selected: f === pickerActiveFamily, - select: () => onModel( + select: () => selectPickerModel( pickVariant(f, pickerActiveVariant?.effort ?? null, pickerDefaultModel).id, ), })); @@ -1136,7 +1042,7 @@ export function ModelPicker({ className={cn( "flex max-h-(--available-height) overflow-hidden", providerRailEnabled - ? "w-[30rem] max-w-(--available-width) flex-row p-0" + ? "w-menu-wide max-w-(--available-width) flex-row p-0" : "w-64 flex-col p-1.5", )} > @@ -1146,7 +1052,7 @@ export function ModelPicker({ data-provider-rail role="listbox" aria-label={t("config.provider")} - className="flex w-14 shrink-0 flex-col items-center gap-1 overflow-y-auto border-r border-border/70 px-2 py-2" + className="flex w-14 shrink-0 flex-col items-center gap-1 overflow-y-auto px-2 py-2" > {providerChoices.map((candidate) => { const selected = candidate.id === pickerProvider; @@ -1157,20 +1063,17 @@ export function ModelPicker({ render={
+
{providerConfig.providersStatus === "loading" ? (

@@ -1199,7 +1103,7 @@ export function ModelPicker({

) : null} {providerConfig.providersStatus === "error" ? ( -
+
{t("config.providersLoadFailed")} diff --git a/apps/desktop/src/session/config.ts b/apps/desktop/src/session/config.ts index 28732134..c2172fbb 100644 --- a/apps/desktop/src/session/config.ts +++ b/apps/desktop/src/session/config.ts @@ -10,18 +10,33 @@ import type { import type { SessionMode } from "./mode"; import type { SceneInfo } from "./scene"; +export function transitionProviderModelSelection({ + hasActiveSession, + createSession, + apply, +}: { + hasActiveSession: boolean; + createSession: () => string | null; + apply: () => void; +}): boolean { + if (hasActiveSession && createSession() === null) return false; + apply(); + return true; +} + /** * Everything configured once per session rather than once per turn. * - * This is one bag of state, but deliberately *not* one panel: each field is reached from its own - * chip in the control row, so choosing a provider shows providers and nothing else. See the pickers - * in `Composer.tsx`. + * This is one bag of state, but deliberately *not* one panel: related Provider/model choices share + * one picker while policy, worktree, scene, and memory settings keep their own controls. */ export interface SessionConfig { providers: ProviderInfo[]; providersStatus: "loading" | "ready" | "error"; provider: string; onProvider: (v: string) => void; + /** A foreign Provider model starts a fresh provider session; never set it on the old session. */ + onProviderModel: (provider: string, model: string) => void; onReloadProviders: () => void; /** The engine's two permission axes. Read here, but set only as a pair — see `onSessionMode`. */ mode: PermissionMode; diff --git a/apps/desktop/src/sidebar/SessionRail.tsx b/apps/desktop/src/sidebar/SessionRail.tsx index cfa337c4..c01eca35 100644 --- a/apps/desktop/src/sidebar/SessionRail.tsx +++ b/apps/desktop/src/sidebar/SessionRail.tsx @@ -13,11 +13,11 @@ import { type ReactNode, } from "react"; import { - DragDropProvider, + DragDropRoot, type DragEndEvent, type DragOverEvent, type DragStartEvent, -} from "@dnd-kit/react"; +} from "@/components/ui/drag-drop"; import { Archive, ArchiveRestore, @@ -134,8 +134,9 @@ import { import { SidebarDropZone, SidebarSortable, + sidebarBeforeIdAtFinalIndex, sidebarDndData, - sidebarProjectSectionFromGroup, + sidebarFinalizedDestination, sidebarSortableSnapshot, type SidebarDndData, type SidebarDragItem, @@ -771,9 +772,17 @@ export function SessionRail({ && eventTarget.item.id === source.id ? lastTarget : eventTarget ?? lastTarget; - if (!source || !target) return; + if (!source) return; + const finalized = sidebarFinalizedDestination(source, sortable); if (source.kind === "section") { + if (finalized?.kind === "sections") { + const sectionIds = taskSections.sections.map((section) => section.id); + const beforeId = sidebarBeforeIdAtFinalIndex(sectionIds, source.id, finalized.index); + setTaskSections((current) => moveSidebarTaskSection(current, source.id, beforeId)); + return; + } + if (!target) return; if (target.item?.kind === "section") { setTaskSections((current) => moveSidebarTaskSection(current, source.id, target.item!.id), @@ -785,26 +794,19 @@ export function SessionRail({ } if (source.kind === "project") { - const sortableSectionId = sortable - ? sidebarProjectSectionFromGroup(sortable.group) - : undefined; - if ( - sortableSectionId !== undefined - && sortable - && ( - sortable.group !== sortable.initialGroup - || sortable.index !== sortable.initialIndex - ) - ) { - const destination = projectPathsForSection(sortableSectionId) - .filter((path) => path !== source.id); + if (finalized?.kind === "projects") { dropProject( source.id, - sortableSectionId, - destination[Math.min(sortable.index, destination.length)] ?? null, + finalized.sectionId, + sidebarBeforeIdAtFinalIndex( + projectPathsForSection(finalized.sectionId), + source.id, + finalized.index, + ), ); return; } + if (!target) return; if (target.item?.kind === "section") { dropProject(source.id, target.item.id, null); } else if (target.item?.kind === "project" && target.location.kind === "projects") { @@ -817,6 +819,24 @@ export function SessionRail({ return; } + if (finalized?.kind === "tasks") { + const destinationTaskIds = finalized.projectPath + ? taskIdsForProject(finalized.projectPath) + : taskIdsForSection(finalized.sectionId); + dropTask( + source.id, + finalized.sectionId, + sidebarBeforeIdAtFinalIndex(destinationTaskIds, source.id, finalized.index), + destinationTaskIds, + finalized.projectPath + ? projectTaskOrderKey(finalized.projectPath) + : finalized.sectionId ?? UNSECTIONED_TASK_ORDER_KEY, + finalized.projectPath, + ); + return; + } + if (!target) return; + let sectionId: string | null; let projectPath: string | null; let beforeTaskId: string | null = null; @@ -850,7 +870,14 @@ export function SessionRail({ : sectionId ?? UNSECTIONED_TASK_ORDER_KEY, projectPath, ); - }, [dropProject, dropTask, projectPathsForSection, taskIdsForProject, taskIdsForSection]); + }, [ + dropProject, + dropTask, + projectPathsForSection, + taskIdsForProject, + taskIdsForSection, + taskSections.sections, + ]); /** One quiet source-list row: task title, workspace identity, and only actionable status. */ const sessionRow = (s: SessionInfo, isArchived: boolean, showProjectIdentity = true) => { @@ -1103,12 +1130,15 @@ export function SessionRail({ collisionPriority={2} disabled={isArchived || renaming?.id === s.id} > - {({ sourceRef, targetRef, isDragging, isDropTarget }) => ( + {({ ref: sortableRef, handleRef, sourceRef, targetRef, isDragging, isDropTarget }) => ( { + sortableRef(element); + targetRef?.(element); + }} data-session-id={s.id} data-sidebar-dragging={isDragging ? "true" : undefined} data-sidebar-drop-target={isDropTarget ? "true" : undefined} @@ -1208,14 +1238,16 @@ export function SessionRail({ className="hidden shrink-0 gap-0.5 group-hover:flex group-focus-within:flex group-data-[popup-open]:flex" > {!isArchived && ( - + + )} {!isArchived && ( - {({ sourceRef, targetRef, isDragging, isDropTarget }) => ( + {({ ref: sortableRef, handleRef, sourceRef, targetRef, isDragging, isDropTarget }) => (
{ + sortableRef(element); + taskDropRef(element); + }} data-project-group={project.path} data-sidebar-dragging={isDragging ? "true" : undefined} data-sidebar-drop-target={isTaskDropTarget ? "true" : undefined} @@ -1505,14 +1540,16 @@ export function SessionRail({ data-sidebar-drop-target={isDropTarget ? "true" : undefined} className="group/project relative flex min-h-control items-center rounded-control pr-1 transition-[background-color,opacity] hover:bg-fill-quiet data-[sidebar-dragging=true]:opacity-45" > - + + - {({ sourceRef, targetRef, isDragging, isDropTarget }) => ( + {({ ref: sortableRef, handleRef, sourceRef, targetRef, isDragging, isDropTarget }) => (
{ + sortableRef(element); + taskDropRef(element); + }} data-task-section-group={section.id} data-sidebar-dragging={isDragging ? "true" : undefined} data-sidebar-drop-target={isTaskDropTarget ? "true" : undefined} @@ -1677,14 +1717,16 @@ export function SessionRail({ /> ) : ( <> - + + -
- + ); } diff --git a/apps/desktop/src/sidebar/sidebarDnd.tsx b/apps/desktop/src/sidebar/sidebarDnd.tsx index d2289cc9..7637dc84 100644 --- a/apps/desktop/src/sidebar/sidebarDnd.tsx +++ b/apps/desktop/src/sidebar/sidebarDnd.tsx @@ -1,6 +1,8 @@ -import { useDroppable } from "@dnd-kit/react"; -import { useSortable } from "@dnd-kit/react/sortable"; import type { ReactNode } from "react"; +import { + useDragDropSortable, + useDragDropZone, +} from "@/components/ui/drag-drop"; export type SidebarDragItem = | { kind: "task"; id: string } @@ -25,8 +27,19 @@ export interface SidebarSortableSnapshot { initialIndex: number; } +export type SidebarFinalizedDestination = + | { kind: "sections"; index: number } + | { kind: "projects"; sectionId: string | null; index: number } + | { + kind: "tasks"; + sectionId: string | null; + projectPath: string | null; + index: number; + }; + interface DndRenderState { ref: (element: Element | null) => void; + handleRef?: (element: Element | null) => void; sourceRef?: (element: Element | null) => void; targetRef?: (element: Element | null) => void; isDragging: boolean; @@ -46,10 +59,10 @@ function dndId(prefix: string, item: SidebarDragItem | undefined, location: Side function dndGroup(item: SidebarDragItem, location: SidebarDropLocation) { if (item.kind === "section") return "sidebar-sections"; if (item.kind === "project" && location.kind === "projects") { - return `sidebar-projects:${location.sectionId ?? "root"}`; + return `sidebar-projects:${encodeURIComponent(location.sectionId ?? "")}`; } if (item.kind === "task" && location.kind === "tasks") { - return `sidebar-tasks:${location.sectionId ?? "root"}:${location.projectPath ?? "none"}`; + return `sidebar-tasks:${encodeURIComponent(location.sectionId ?? "")}:${encodeURIComponent(location.projectPath ?? "")}`; } return `sidebar-${item.kind}`; } @@ -71,7 +84,7 @@ export function SidebarSortable({ disabled?: boolean; children: (state: DndRenderState) => ReactNode; }) { - const sortable = useSortable({ + const sortable = useDragDropSortable({ id: dndId("item", item, location), index, group: dndGroup(item, location), @@ -84,6 +97,7 @@ export function SidebarSortable({ return children({ ref: sortable.ref, + handleRef: sortable.handleRef, sourceRef: sortable.sourceRef, targetRef: sortable.targetRef, isDragging: sortable.isDragging, @@ -103,7 +117,7 @@ export function SidebarDropZone({ children: (state: DndRenderState) => ReactNode; }) { const acceptKey = Array.isArray(accept) ? [...accept].sort().join("-") : accept; - const droppable = useDroppable({ + const droppable = useDragDropZone({ id: dndId(`zone:${acceptKey}`, undefined, location), accept, collisionPriority, @@ -133,12 +147,71 @@ export function sidebarSortableSnapshot(value: unknown): SidebarSortableSnapshot || typeof candidate.index !== "number" || typeof candidate.initialIndex !== "number" ) return null; - return candidate as SidebarSortableSnapshot; + return { + group: candidate.group, + initialGroup: candidate.initialGroup, + index: candidate.index, + initialIndex: candidate.initialIndex, + }; } export function sidebarProjectSectionFromGroup(group: string): string | null | undefined { const prefix = "sidebar-projects:"; if (!group.startsWith(prefix)) return undefined; - const sectionId = group.slice(prefix.length); - return sectionId === "root" ? null : sectionId; + const sectionId = decodeURIComponent(group.slice(prefix.length)); + return sectionId === "" ? null : sectionId; +} + +export function sidebarTaskLocationFromGroup( + group: string, +): Extract | undefined { + const prefix = "sidebar-tasks:"; + if (!group.startsWith(prefix)) return undefined; + const separator = group.indexOf(":", prefix.length); + if (separator < 0) return undefined; + const sectionId = decodeURIComponent(group.slice(prefix.length, separator)); + const projectPath = decodeURIComponent(group.slice(separator + 1)); + return { + kind: "tasks", + sectionId: sectionId === "" ? null : sectionId, + projectPath: projectPath === "" ? null : projectPath, + }; +} + +export function sidebarBeforeIdAtFinalIndex( + destinationIds: readonly string[], + sourceId: string, + finalIndex: number, +): string | null { + const remaining = destinationIds.filter((id) => id !== sourceId); + const boundedIndex = Math.min(Math.max(0, finalIndex), remaining.length); + return remaining[boundedIndex] ?? null; +} + +/** Translate dnd-kit's authoritative final sortable state into a sidebar destination. */ +export function sidebarFinalizedDestination( + item: SidebarDragItem, + snapshot: SidebarSortableSnapshot | null, +): SidebarFinalizedDestination | undefined { + if ( + !snapshot + || ( + snapshot.group === snapshot.initialGroup + && snapshot.index === snapshot.initialIndex + ) + ) return undefined; + + if (item.kind === "section") { + return snapshot.group === "sidebar-sections" + ? { kind: "sections", index: snapshot.index } + : undefined; + } + if (item.kind === "project") { + const sectionId = sidebarProjectSectionFromGroup(snapshot.group); + return sectionId === undefined + ? undefined + : { kind: "projects", sectionId, index: snapshot.index }; + } + const location = sidebarTaskLocationFromGroup(snapshot.group); + return location ? { ...location, index: snapshot.index } : undefined; } diff --git a/apps/desktop/tests/checkoutPickerRendered.test.tsx b/apps/desktop/tests/checkoutPickerRendered.test.tsx index 47c9de4a..51af7150 100644 --- a/apps/desktop/tests/checkoutPickerRendered.test.tsx +++ b/apps/desktop/tests/checkoutPickerRendered.test.tsx @@ -18,6 +18,7 @@ function config(overrides = {}) { providersStatus: "ready", provider: "codex", onProvider: () => {}, + onProviderModel: () => {}, onReloadProviders: () => {}, mode: "ask", sandbox: "workspace_write", diff --git a/apps/desktop/tests/providerModelTransition.test.ts b/apps/desktop/tests/providerModelTransition.test.ts new file mode 100644 index 00000000..46ba7908 --- /dev/null +++ b/apps/desktop/tests/providerModelTransition.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { transitionProviderModelSelection } from "../src/session/config"; + +describe("App Provider/model draft transition", () => { + test("creates a fresh draft before applying a foreign Provider model", () => { + const events: string[] = []; + + const changed = transitionProviderModelSelection({ + hasActiveSession: true, + createSession: () => { + events.push("create"); + return "/tmp/project"; + }, + apply: () => events.push("apply:grok:grok-4.6"), + }); + + expect(changed).toBe(true); + expect(events).toEqual(["create", "apply:grok:grok-4.6"]); + }); + + test("leaves Provider and model untouched when draft creation fails", () => { + const events: string[] = []; + + const changed = transitionProviderModelSelection({ + hasActiveSession: true, + createSession: () => { + events.push("create:failed"); + return null; + }, + apply: () => events.push("apply"), + }); + + expect(changed).toBe(false); + expect(events).toEqual(["create:failed"]); + }); + + test("applies directly when the Composer already owns a blank draft", () => { + const events: string[] = []; + + const changed = transitionProviderModelSelection({ + hasActiveSession: false, + createSession: () => { + events.push("unexpected-create"); + return null; + }, + apply: () => events.push("apply:codex:gpt-5.6-sol"), + }); + + expect(changed).toBe(true); + expect(events).toEqual(["apply:codex:gpt-5.6-sol"]); + }); +}); diff --git a/apps/desktop/tests/sceneChip.test.tsx b/apps/desktop/tests/sceneChip.test.tsx index 78988270..10355f94 100644 --- a/apps/desktop/tests/sceneChip.test.tsx +++ b/apps/desktop/tests/sceneChip.test.tsx @@ -5,7 +5,7 @@ import { activateDom, button, click, dom, flush, mount, restoreDom } from "./dom activateDom(); const { SceneChip, ScenePicker, SourceBadge } = await import("../src/session/SceneChip"); -const { ProviderPicker, SessionControls } = await import("../src/session/Composer"); +const { SessionControls } = await import("../src/session/Composer"); const { I18nProvider } = await import("../src/i18n"); afterEach(() => { @@ -36,6 +36,7 @@ function config(overrides = {}) { providersStatus: "ready", provider: "claude_code", onProvider: () => {}, + onProviderModel: () => {}, onReloadProviders: () => {}, mode: "ask", sandbox: "workspace_write", @@ -76,23 +77,29 @@ function renderChip(cfg) { ); } -describe("ProviderPicker", () => { +describe("Provider/model picker", () => { test("keeps known providers selectable and offers retry when desktop detection fails", async () => { activateDom(); let retries = 0; const rendered = mount( - { retries += 1; }, })} + models={[]} + currentModel={null} + defaultModel={null} + onModel={() => {}} + configOptions={[]} + onConfigOption={() => {}} /> , ); - const trigger = rendered.container.querySelector("button"); + const trigger = rendered.container.querySelector('button[title="Model"]'); await reactAct(async () => { trigger?.dispatchEvent(new dom.window.PointerEvent("pointerdown", { @@ -107,9 +114,9 @@ describe("ProviderPicker", () => { try { const popup = dom.document.body.querySelector('[data-slot="popover-content"]'); - expect(trigger?.textContent?.trim()).toBe("Grok"); - expect(popup?.textContent).toContain("Grok"); - expect(popup?.textContent).toContain("Codex"); + expect(trigger?.textContent).toContain("Default model"); + button(popup, "Grok"); + button(popup, "Codex"); button(popup, "Retry").click(); expect(retries).toBe(1); } finally { @@ -237,14 +244,16 @@ describe("SceneChip", () => { ]; const providerChanges = []; const modelChanges = []; + const providerModelChanges = []; const rendered = mount( providerChanges.push(provider), + onProviderModel: (provider, model) => providerModelChanges.push([provider, model]), })} models={providers[0].models} currentModel="gpt-5.6-sol" @@ -273,14 +282,15 @@ describe("SceneChip", () => { click(grok); await flush(); - expect(providerChanges).toEqual(["grok"]); + expect(providerChanges).toEqual([]); const grokModel = Array.from( picker?.querySelectorAll('[data-model-picker-row] [data-slot="selectable-row"]') ?? [], ).find((row) => row.textContent?.includes("Grok 4.6")); if (!grokModel) throw new Error("Grok model did not render after switching Provider"); click(grokModel); await flush(); - expect(modelChanges).toEqual(["grok-4.6"]); + expect(modelChanges).toEqual([]); + expect(providerModelChanges).toEqual([["grok", "grok-4.6"]]); rendered.unmount(); }); diff --git a/apps/desktop/tests/sessionRailRendered.test.tsx b/apps/desktop/tests/sessionRailRendered.test.tsx index 03d85f68..c54cbc04 100644 --- a/apps/desktop/tests/sessionRailRendered.test.tsx +++ b/apps/desktop/tests/sessionRailRendered.test.tsx @@ -285,25 +285,25 @@ describe("SessionRail row layout", () => { previews: {}, }); - const secondButton = view.container.querySelector( - '[data-session-id="second"] [data-session-select]', + const secondHandle = view.container.querySelector( + '[data-session-id="second"] [data-session-drag-handle]', ); - if (!secondButton) throw new Error("missing Task drag fixture"); + if (!secondHandle) throw new Error("missing Task drag fixture"); - const repoButton = view.container.querySelector( - '[data-project-toggle="/tmp/repo"]', + const repoHandle = view.container.querySelector( + '[data-project-drag-handle="/tmp/repo"]', ); - if (!repoButton) throw new Error("missing Project drag fixture"); + if (!repoHandle) throw new Error("missing Project drag fixture"); await waitFor(() => { - expect(repoButton.getAttribute("aria-roledescription")).toBe("draggable"); - expect(secondButton.getAttribute("aria-roledescription")).toBe("draggable"); + expect(repoHandle.getAttribute("aria-roledescription")).toBe("draggable"); + expect(secondHandle.getAttribute("aria-roledescription")).toBe("draggable"); }); - expect(repoButton.getAttribute("draggable")).toBeNull(); - expect(secondButton.getAttribute("draggable")).toBeNull(); + expect(repoHandle.getAttribute("draggable")).toBeNull(); + expect(secondHandle.getAttribute("draggable")).toBeNull(); expect(view.container.querySelector("[draggable=true]")).toBeNull(); const description = dom.document.getElementById( - repoButton.getAttribute("aria-describedby") ?? "", + repoHandle.getAttribute("aria-describedby") ?? "", ); expect(description?.textContent).toContain("use the arrow keys"); expect(view.container.querySelector( diff --git a/apps/desktop/tests/sidebarDnd.test.ts b/apps/desktop/tests/sidebarDnd.test.ts new file mode 100644 index 00000000..f85f515a --- /dev/null +++ b/apps/desktop/tests/sidebarDnd.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { + sidebarBeforeIdAtFinalIndex, + sidebarFinalizedDestination, + sidebarProjectSectionFromGroup, + sidebarTaskLocationFromGroup, +} from "../src/sidebar/sidebarDnd"; + +describe("sidebar dnd destination mapping", () => { + test("uses the finalized sortable index when moving down or up", () => { + const ids = ["a", "b", "c"]; + + expect(sidebarBeforeIdAtFinalIndex(ids, "a", 2)).toBeNull(); + expect(sidebarBeforeIdAtFinalIndex(ids, "c", 0)).toBe("a"); + expect(sidebarBeforeIdAtFinalIndex(ids, "b", 1)).toBe("c"); + }); + + test("decodes Project and Task destination groups without losing paths or Section ids", () => { + expect(sidebarProjectSectionFromGroup("sidebar-projects:")).toBeNull(); + expect(sidebarProjectSectionFromGroup("sidebar-projects:work%3Aurgent")).toBe("work:urgent"); + expect(sidebarTaskLocationFromGroup( + "sidebar-tasks:work%3Aurgent:%2Ftmp%2Frepo%3Afeature", + )).toEqual({ + kind: "tasks", + sectionId: "work:urgent", + projectPath: "/tmp/repo:feature", + }); + }); + + test("maps finalized Section, Project, and Task destinations from dnd-kit", () => { + const snapshot = (group: string, index: number) => ({ + group, + initialGroup: "old-group", + index, + initialIndex: 0, + }); + + expect(sidebarFinalizedDestination( + { kind: "section", id: "work" }, + snapshot("sidebar-sections", 2), + )).toEqual({ kind: "sections", index: 2 }); + expect(sidebarFinalizedDestination( + { kind: "project", id: "/tmp/repo" }, + snapshot("sidebar-projects:work%3Aurgent", 1), + )).toEqual({ kind: "projects", sectionId: "work:urgent", index: 1 }); + expect(sidebarFinalizedDestination( + { kind: "task", id: "task-1" }, + snapshot("sidebar-tasks::%2Ftmp%2Frepo", 3), + )).toEqual({ + kind: "tasks", + sectionId: null, + projectPath: "/tmp/repo", + index: 3, + }); + }); +}); diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs index a1c5a9f0..e1c84708 100644 --- a/crates/core/src/store.rs +++ b/crates/core/src/store.rs @@ -1940,32 +1940,62 @@ impl Store { pub fn last_texts(&self) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT p.session_id, p.part_json FROM parts p - JOIN sessions s ON s.id=p.session_id AND s.transient=0 AND s.handoff_state<>'accepted' - WHERE p.seq = ( - SELECT MAX(q.seq) FROM parts q - WHERE q.session_id = p.session_id - AND q.role='\"agent\"' - AND json_extract(q.part_json,'$.kind')='text' - )", + "WITH latest_agent_text AS ( + SELECT p.session_id, MAX(p.seq) AS final_seq + FROM parts p + JOIN sessions s + ON s.id=p.session_id AND s.transient=0 AND s.handoff_state<>'accepted' + WHERE p.role='\"agent\"' + AND json_extract(p.part_json,'$.kind')='text' + GROUP BY p.session_id + ) + SELECT p.session_id, p.part_json + FROM latest_agent_text latest + JOIN parts p ON p.session_id=latest.session_id + WHERE p.role='\"agent\"' + AND json_extract(p.part_json,'$.kind')='text' + AND p.seq <= latest.final_seq + AND p.seq > COALESCE(( + SELECT MAX(user_part.seq) + FROM parts user_part + WHERE user_part.session_id=latest.session_id + AND user_part.role='\"user\"' + AND user_part.seq < latest.final_seq + ), -1) + ORDER BY p.session_id, p.seq", )?; let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; let mut out = Vec::new(); + let mut current_id = None::; + let mut current_text = String::new(); + let push_current = + |out: &mut Vec<(String, String)>, id: &mut Option, text: &mut String| { + let Some(id) = id.take() else { return }; + let flat = text.split_whitespace().collect::>().join(" "); + text.clear(); + if !flat.is_empty() { + out.push((id, flat.chars().take(160).collect())); + } + }; for row in rows.flatten() { let (id, json) = row; + if current_id.as_deref().is_some_and(|current| current != id) { + push_current(&mut out, &mut current_id, &mut current_text); + } + if current_id.is_none() { + current_id = Some(id); + } if let Ok(part) = serde_json::from_str::(&json) { let text = match part { Part::Text { text } => text, Part::Prompt { display, .. } => display, _ => continue, }; - let flat = text.split_whitespace().collect::>().join(" "); - if !flat.is_empty() { - out.push((id, flat.chars().take(160).collect())); - } + current_text.push_str(&text); } } + push_current(&mut out, &mut current_id, &mut current_text); Ok(out) } @@ -4241,7 +4271,16 @@ mod tests { &a.id, Role::Agent, &Part::Text { - text: " second\n answer ".into(), + text: " second\n".into(), + }, + ) + .unwrap(); + store + .append_part( + &a.id, + Role::Agent, + &Part::Text { + text: " answer ".into(), }, ) .unwrap(); @@ -4275,8 +4314,8 @@ mod tests { let previews = store.last_texts().unwrap(); assert_eq!(previews.len(), 1); assert_eq!(previews[0].0, a.id); - // Whitespace is flattened so a multi-line answer stays one line in the rail, and the - // later user prompt does not replace the newest AI reply. + // Consecutive streaming chunks form one Agent answer before whitespace is flattened; the + // later user prompt still must not replace that newest AI reply. assert_eq!(previews[0].1, "second answer"); } diff --git a/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md b/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md index 8686ad4d..070feb74 100644 --- a/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md +++ b/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with screenshot asking to adjust the environment popover position inputs: EnvironmentPopover placement props and the shared Base UI Popover collision behavior outputs: environment popover opens to the right from the header trigger scope: apps/desktop/src/environment/EnvironmentPopover.tsx, apps/desktop/tests/environmentPopoverRendered.test.tsx, docs/sdlc/changes/2026-08-31-align-environment-popover -next_trigger: PR review; merge and release remain pending +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Align the environment popover @@ -68,18 +68,20 @@ unchanged. ## Verification -Verdict: verified. +Verdict: verified ### Acceptance evidence - AC-1: PASS — isolated Browser rendering measured the Environment trigger at left `885.57 px` - and the popup at left `885.5 px`; the Base UI positioner reported `data-align="start"`. + and the popup at left `885.5 px`; the Base UI positioner reported `data-align="start"`. The + [light rendered placement](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/environment-light.png) + records the resulting rightward surface. - AC-2: PASS — the rendered popup occupied `885.5–1173.5 px` inside a `1280 px` viewport. It retained Settings, Changes, Local, Git state, and Commit or push; clicking Local expanded the existing Add a project action. - AC-3: PASS — `bun test tests/environmentPopoverRendered.test.tsx` passed four tests and 39 - expectations; targeted TypeScript and ESLint passed; full `bun test` passed 797 tests and 3,808 - expectations; and `bun run build:renderer` completed lint, TypeScript, and the Vite production + expectations; targeted TypeScript and ESLint passed; final full `bun test` passed 806 tests and + 3,841 expectations; and `bun run build:renderer` completed lint, TypeScript, and the Vite production build. `bun test script/verify/checks.test.ts`, `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, and `bun script/verify/sdlc.ts --worktree` all passed. Rendered DOM, interaction, geometry measurement, and screenshot inspection passed. diff --git a/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md b/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md index f095b6e5..74b1a8d6 100644 --- a/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md +++ b/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with screenshot highlighting the sidebar search shortcut alignment inputs: SessionRail search launcher width and inset behavior outputs: evenly inset search launcher with an aligned shortcut badge scope: apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut -next_trigger: PR review; merge and release remain pending +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Align the sidebar search shortcut @@ -63,7 +63,7 @@ a full-width row. No shortcut, typography, height, interaction, or shared Button ## Verification -Verdict: verified. +Verdict: verified ### Acceptance evidence @@ -72,7 +72,8 @@ Verdict: verified. Browser geometry at 1280 x 720 measured equal `8 px` left and right rail insets. - AC-2: PASS — Browser `playwright.evaluate(getBoundingClientRect)` measured the shortcut's vertical center delta from the launcher at `0 px` and confirmed all four shortcut bounds - remained inside the launcher. + remained inside the launcher. The final [dark sidebar screenshot](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-dark.png) + records the contained `⌘K` badge and equal launcher insets. - AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx` passed 26 tests and 252 expectations; clicking the rendered launcher opened the Command palette and Browser console errors/warnings remained empty. diff --git a/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md b/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md index cf0d8807..e7d2401f 100644 --- a/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md +++ b/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with screenshot of the split-right and split-down menu actions inputs: PaneTiles new-leaf rendering and the repository semantic motion contract outputs: direction-aware entrance animation for newly split panes scope: apps/desktop/src/session/PaneTiles.tsx, apps/desktop/src/styles.css, apps/desktop/tests/paneTiles.test.tsx, docs/sdlc/changes/2026-08-31-animate-pane-splits -next_trigger: PR review; merge and release remain pending +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Animate new pane splits @@ -77,13 +77,14 @@ continues to collapse it. ## Verification -Verdict: verified. +Verdict: verified ### Acceptance evidence - AC-1: PASS — `Browser Split right / Split down interaction` created `pane-2` with `data-pane-entrance="right"` and `pane-3` with `data-pane-entrance="bottom"`; both rendered - `animation-name: pane-tile-enter`. + `animation-name: pane-tile-enter`. The [light final split state](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/split-animation-end-light.png) + records the stable end geometry; live interaction and computed styles prove the motion itself. - AC-2: PASS — `bun test tests/paneTiles.test.tsx tests/paneChrome.test.tsx` verifies that the initial pane has no entrance marker, existing pane DOM identity is preserved across rerender, and only newly added leaves receive an entrance class. @@ -91,7 +92,7 @@ Verdict: verified. `cubic-bezier(0.16, 1, 0.3, 1)` for both menu actions. The focused test covers left, right, top, and bottom. Setting Appearance → Reduce motion → On reduced a fresh split to `1e-05s`. - AC-4: PASS — the focused pane suites passed nine tests and 49 expectations; `bunx tsc --noEmit` - and targeted ESLint passed; full `bun test` passed 797 tests and 3,816 expectations; and + and targeted ESLint passed; final full `bun test` passed 806 tests and 3,841 expectations; and `bun run build:renderer` completed lint, TypeScript, and the Vite production build. Browser page identity, meaningful content, menu interaction, screenshot inspection, and console checks passed with no relevant warnings or errors. diff --git a/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md b/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md index 98c834f4..f38f4744 100644 --- a/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md +++ b/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with screenshot highlighting the non-working composer resize grip inputs: current Composer grip, resize state, styles, and translated label outputs: composer without the unusable top resize affordance or its dormant feature wiring scope: apps/desktop/src/App.tsx, apps/desktop/src/session/Composer.tsx, apps/desktop/src/styles.css, apps/desktop/src/i18n/strings.ts, apps/desktop/tests/composerGeometryContract.test.ts, docs/sdlc/changes/2026-08-31-remove-composer-resize-grip -next_trigger: PR review; merge and release remain pending +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Remove the composer resize grip @@ -73,17 +73,20 @@ unchanged. ## Verification -Verdict: verified. +Verdict: verified ### Acceptance evidence - AC-1: PASS — `Browser rendered compact Composer check` found zero `.composer-grip` elements. The first document child begins at the exact card top, proving the deleted 11 px grip did not - leave a spacer, and computed card radius remains `24px` at 1280x720. + leave a spacer, and computed card radius remains `24px` at 1280x720. The shared + [narrow rendered shell](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-narrow-dark.png) + supplies the constrained-viewport evidence; the zero-element and geometry measurements are the + acceptance evidence for the removed affordance itself. - AC-2: PASS — `rg -n 'composer-grip|composer\.grip|codetwo\.composerHeight|composerHByPane' apps/desktop/src` returned no matches. `bun test tests/composerGeometryContract.test.ts` passed four tests and 34 expectations, including the fixed 190 px bound and explicit full-page button. -- AC-3: PASS — `bun test` passed 796 tests and 3,794 expectations; `bunx tsc --noEmit` passed; +- AC-3: PASS — final `bun test` passed 806 tests and 3,841 expectations; `bunx tsc --noEmit` passed; `bun run build:renderer` completed lint, TypeScript, and the Vite production build. Browser page identity, meaningful content, compact/full-page interaction, framework-overlay inspection, screenshot inspection, and console checks passed with no relevant warnings or errors. diff --git a/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md b/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md index c9484479..8d98b5f9 100644 --- a/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md +++ b/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with screenshot marking the Project-row disclosure icon inputs: current desktop SessionRail Project header and screenshot feedback outputs: Project rows without a trailing disclosure icon while retaining whole-row folding scope: apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon -next_trigger: PR review; merge and release remain pending +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Remove the Project disclosure icon @@ -64,16 +64,17 @@ content. No material deviation from the Plan was required. ## Verification -Verdict: verified. +Verdict: verified ### Acceptance evidence - AC-1: PASS — `bun test tests/sessionRailRendered.test.tsx` passed 24 tests and the 954 by 858 in-app Browser inspection showed `codeTwo`, `open-mole`, and `MacOS` with one folder SVG each and - no trailing disclosure arrow. + no trailing disclosure arrow. The final [dark rendered sidebar](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-dark.png) + records the same Project-header result. - AC-2: PASS — the focused test and Browser interaction changed the `codeTwo` header from `aria-expanded=true` to `false`, removed its Project content, then restored the expanded content. -- AC-3: PASS — full `bun test` passed 795 tests across 137 files with 3,799 expectations; +- AC-3: PASS — final full `bun test` passed 806 tests across 139 files with 3,841 expectations; `bun run build:renderer`, `bun test script/verify/checks.test.ts`, `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, and `bun script/verify/sdlc.ts --worktree` passed; the dark rendered view had no relevant console diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md index 3b98ea61..35e7b05f 100644 --- a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md +++ b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with screenshot showing failed Project sorting and folder placement inputs: current desktop SessionRail drag behavior and physical pointer-drag reproduction outputs: library-backed sidebar sorting and Project-to-Section placement -scope: apps/desktop/package.json, apps/desktop/bun.lock, apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/src/sidebar/sidebarDnd.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit -next_trigger: PR review; merge and release remain pending +scope: apps/desktop/package.json, apps/desktop/bun.lock, apps/desktop/src/components/ui/drag-drop.tsx, apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/src/sidebar/sidebarDnd.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, apps/desktop/tests/sidebarDnd.test.ts, docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Replace sidebar drag handling with dnd-kit @@ -56,8 +56,8 @@ remains required before merge. No release or production action is authorized. ## Plan -1. Add the maintained `@dnd-kit/react` package and a small typed adapter for sidebar items and - drop locations. +1. Add the maintained `@dnd-kit/react` package behind a shared UI primitive and a small typed + adapter for sidebar items and drop locations. 2. Replace native drag handlers with `DragDropProvider`, sortable rows, and explicit empty-list drop zones while retaining the existing state mutation callbacks. 3. Update focused rendered coverage and run type, unit, build, lifecycle, and physical pointer-drag @@ -67,37 +67,43 @@ Rollback removes the dependency and adapter and restores the previous sidebar dr ## Build -Added the pinned `@dnd-kit/react` dependency and a typed sidebar adapter for sortable rows and -explicit drop zones. `SessionRail` now wraps Section, Project, and Task rows in the library's -provider and preserves the existing domain operations for persisted moves. Native HTML5 -`draggable`, `dragstart`, `dataTransfer`, and `drop` handling was removed. +Added the pinned `@dnd-kit/react` dependency behind the shared `components/ui/drag-drop` primitive +and a typed sidebar adapter for sortable rows and explicit drop zones. `SessionRail` now registers +the complete Section, Project, and Task row as each sortable element, exposes a dedicated keyboard +drag handle, and preserves the existing domain operations for persisted moves. Feature code no +longer imports the third-party package directly. Native HTML5 `draggable`, `dragstart`, +`dataTransfer`, and `drop` handling was removed. -Project moves decode the library's final sortable group and index so a same-list sort, a +Project moves decode the library's finalized sortable group and index so a same-list sort, a cross-Section move, and a move back to root all resolve against the actual destination rather than -the optimistic source row. Empty Section and empty root drop zones have distinct IDs and explicit +a stale hover row. Group components are URI-encoded, so paths and Section IDs containing colons +remain unambiguous. Empty Section and empty root drop zones have distinct IDs and explicit collision priority, so another item kind cannot overwrite the registered target. ## Verification -Verdict: verified. +Verdict: verified ### Acceptance evidence -- AC-1: PASS — `Browser physical pointer drag` in the isolated rendered desktop shell changed the root - Project order from `open-mole, codeTwo, MacOS` to `open-mole, MacOS, codeTwo`; a hard reload kept - the persisted renderer state. +- AC-1: PASS — `Browser physical pointer drag` used a paced Chromium pointer path in the isolated rendered desktop shell + changed the root Project order from `codeTwo, open-mole, MacOS` to + `open-mole, MacOS, codeTwo`; dnd-kit finalized the source at index 2 instead of retaining its + stale hover index. See the [dark baseline](evidence/pr-review-dark.png) and + [post-drag state](evidence/drag-result-dark.png). - AC-2: PASS — physical pointer dragging moved `codeTwo` into the empty `Work` Section, and a - separate pointer drag moved `open-mole` from `Work` back to the root Project list. -- AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx tests/sidebarProjects.test.ts - tests/sidebarSections.test.ts` verifies the rendered DOM exposes dnd-kit's draggable semantics - and keyboard instructions - while containing no native sidebar `[draggable=true]` elements. Section, Project, and Task rows - retain their existing typed move callbacks; the Project and Section domain suites pass. -- AC-4: PASS — focused sidebar suites passed 31 tests and 262 expectations; `bunx tsc --noEmit` - passed; the full desktop suite passed 795 tests and 3,787 expectations; and - `bun run build:renderer` completed lint, TypeScript, and the Vite production build. The isolated - rendered pass covered same-list sorting, placement into an empty Section, return to root, and - the final visual state without starting a second Core process. + separate drag moved `open-mole` from `Work` back to the root Project list. After the final + sortable-row registration correction, an actual keyboard gesture (`Enter`, `ArrowUp`, `Enter`) + also moved `codeTwo` into the empty `Work` Section. +- AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx` verifies that Section, Project, and + Task rows expose dedicated dnd-kit keyboard handles and no native `[draggable=true]` elements. + `bun test tests/sidebarDnd.test.ts tests/sidebarProjects.test.ts tests/sidebarSections.test.ts` + verifies finalized index mapping plus Section, Project, and Task destination decoding, including + encoded paths. Existing typed domain move suites pass. +- AC-4: PASS — `bun test` plus TypeScript, focused suites, and the renderer production + build passed in the final verification pass. The isolated rendered pass covered same-list + pointer sorting, empty-Section keyboard placement, the [narrow shell](evidence/pr-review-narrow-dark.png), + and the final visual state without starting a second Core process. The initial native-HTML5 baseline emitted `pointerdown` but not `dragstart`. During replacement, the first library pass exposed two integration defects: optimistic same-list sorting reported the @@ -105,10 +111,10 @@ source row as the final target, and Project/Task empty drop zones shared an ID. uses sortable destination metadata and kind-qualified drop-zone IDs; both failure paths were retested after correction. -Residual risk: the isolated renderer verified library-provided keyboard semantics and instructions, -but the complete keyboard reorder gesture was not manually exercised. The user's live Core-backed -profile was deliberately not opened because another process owns it; the unchanged persistence -operations are covered by domain tests, and renderer persistence was checked across reload. +Residual risk: the user's live Core-backed profile was deliberately not opened because another +process owns it. Pointer and keyboard gestures were exercised in the isolated Chromium renderer; +the unchanged Core persistence operations are covered by domain tests rather than a second live +Core instance. ## Review and release diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/drag-result-dark.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/drag-result-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c74afe15d6c612ea74ca07ebcf5b14ee437a8edb GIT binary patch literal 31519 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 ZUY2}Xn!n>_ONX%cU$(zQy6p4Fe**%$mGuAs literal 0 HcmV?d00001 diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/environment-light.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/environment-light.png new file mode 100644 index 0000000000000000000000000000000000000000..7cf109119f42d5f807acb02718d0e77ec29a4ff0 GIT binary patch literal 36731 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�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

-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 literal 0 HcmV?d00001 diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-dark.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e1db53ceb3e485702ddae35321ce69d3e7800a9e GIT binary patch literal 36780 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 literal 0 HcmV?d00001 diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-narrow-dark.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-narrow-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..35c9f6d55920d1de95361a53aed6ea069d870164 GIT binary patch literal 17415 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 literal 0 HcmV?d00001 diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/split-animation-end-light.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/split-animation-end-light.png new file mode 100644 index 0000000000000000000000000000000000000000..6b231661ab3c74a1c806f4f84314e04605d678e2 GIT binary patch literal 35016 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 literal 0 HcmV?d00001 diff --git a/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md b/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md index 1252142b..4e04b577 100644 --- a/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md +++ b/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with screenshot showing missing session summary metadata in the sidebar inputs: persisted transcript previews, session provider and activity timestamps, SessionRail row layout outputs: sidebar session summary line with latest AI reply, provider mark, and compact relative age scope: crates/core/src/store.rs, apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary -next_trigger: PR review; merge and release remain pending +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Restore the sidebar session summary @@ -36,8 +36,9 @@ and a compact relative age derived from `last_active_at` with `created_at` fallb no Agent text retain the line with Provider and age, without inventing reply content. Workspace, checkout, worktree, and pull-request provenance remain on the following line. -The Core preview query must select only the newest Agent text. It must not replace an existing AI -reply with a later user prompt. Preserve the existing one-query projection and 160-character bound. +The Core preview query must select the complete newest Agent reply, coalescing all of its streamed +text chunks. It must not replace an existing AI reply with a later user prompt. Preserve the +existing one-query projection and 160-character bound. ### Acceptance criteria @@ -66,35 +67,37 @@ Rollback restores the previous preview query and SessionRail row projection. ## Build -The Core preview projection now selects only the newest Agent text, preserving the existing -one-query and bounded-preview behavior. SessionRail renders a fixed summary line with the shared -ProviderIcon, a one-line Agent preview when present, and compact relative activity age driven by -one rail-wide minute ticker. Workspace and Git provenance remain on the following line. +The Core preview projection now finds the latest Agent reply per durable session and coalesces all +of its text chunks after the preceding User part, preserving the existing one-query and +bounded-preview behavior. A later User prompt cannot replace that reply. SessionRail renders a +fixed summary line with the shared ProviderIcon, a one-line Agent preview when present, and compact +relative activity age driven by one rail-wide minute ticker. Workspace and Git provenance remain +on the following line. ## Verification -Verdict: verified. +Verdict: verified ### Acceptance evidence - AC-1: PASS — `bun test tests/sessionRailRendered.test.tsx` passed 25 tests and 249 expectations. Browser inspection of the real SessionRail at 320 px showed Provider marks, - single-line truncated AI replies, and `5m` / `2h` ages on the second line. + single-line truncated AI replies, and compact ages on the second line. See the + [dark rendered sidebar](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-dark.png). - AC-2: PASS — `cargo test -p codetwo-core last_texts_returns_the_newest_agent_text_per_session` and all 50 Core store tests passed, - including a later user prompt that must not displace the latest Agent reply. + including a streamed `second answer` split across two Agent chunks and a later user prompt that + must not displace the complete latest reply. - AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx` retained title, activity, action, drag, workspace, checkout/worktree, and pull-request assertions; the full `bun test` suite - passed 798 tests and 3,773 expectations. + passed 806 tests and 3,841 expectations. - AC-4: PASS — full `cargo test -p codetwo-core`, `bun run build:renderer`, TypeScript, focused ESLint, Browser DOM/geometry inspection, all five verification-script tests, documentation verification, and both committed and worktree SDLC checks passed. Residual risk: the rendered inspection used an isolated fixture-backed renderer so it would not start or interfere with the user's live Core. Existing persisted sessions require the updated Core -and renderer to be launched together before the repaired projection appears. The repository-wide -`cargo fmt --all --check` remains blocked by pre-existing formatting drift in unrelated Rust files; -this low-risk UI change did not rewrite those files. +and renderer to be launched together before the repaired projection appears. ## Review and release diff --git a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md index d14474ef..55ee58a7 100644 --- a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md +++ b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 +updated: 2026-09-01 source: direct user request with T3 model-picker reference and current CodeTwo screenshot inputs: current Provider registry, provider-owned model choices, model favorites and preferences outputs: one Composer model entry with Provider rail, search, and model selection -scope: apps/desktop/src/session/Composer.tsx, apps/desktop/tests/sceneChip.test.tsx, docs/sdlc/changes/2026-08-31-simplify-provider-model-picker -next_trigger: PR review; merge and release remain pending +scope: apps/desktop/src/App.tsx, apps/desktop/src/session/Composer.tsx, apps/desktop/src/session/config.ts, apps/desktop/tests/checkoutPickerRendered.test.tsx, apps/desktop/tests/providerModelTransition.test.ts, apps/desktop/tests/sceneChip.test.tsx, docs/sdlc/changes/2026-08-31-simplify-provider-model-picker +next_trigger: human review and merge decision on PR #208 verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-01 --- # Simplify Provider and model selection @@ -33,8 +33,9 @@ In the primary Composer controls, remove the separate visible Provider chip. Ext model picker with a narrow Provider icon rail and keep the searchable model list beside it. Reuse the current Provider registry, ProviderIcon, model grouping, favorites, hidden-model preferences, adapter-owned model options, disabled states, and existing callbacks. Provider loading, retry, and -unavailable states remain legible. Selecting a Provider updates the existing Provider state and -keeps the surface open so a model can be selected directly. +unavailable states remain legible. Choosing a Provider in the rail browses its models locally and +keeps the surface open. Selecting a model owned by another Provider applies Provider and model +atomically to a fresh session; it must never call `setModel` on the old Provider session. Do not copy T3-only shortcut labels, legacy sections, or new favorite semantics. Keep reasoning effort independent because it is a separate provider-owned configuration axis. ModelPicker uses in @@ -46,8 +47,8 @@ Side Chat and Project Settings remain model-only and retain their current compac and model chips. - [x] AC-2: The unified popup has a Provider icon rail, model search, and a scrollable model list whose selected/favorite/default states reuse existing behavior. -- [x] AC-3: Switching Provider updates the existing Provider callback and reveals that Provider's - models without closing the popup; selecting a model uses the existing model callback. +- [x] AC-3: Switching the rail reveals that Provider's models without mutating the current session; + selecting a foreign model starts a fresh session and applies Provider/model atomically. - [x] AC-4: Reasoning effort, disabled/running behavior, provider loading/error states, Side Chat, and Project Settings model-only use remain intact. - [x] AC-5: Focused/full tests, renderer build, rendered Browser interaction and reference-image @@ -71,13 +72,19 @@ Provider rail from ModelPicker. ## Build `SessionControls` now renders one `ModelPicker` entry for Provider and model selection. The -Provider-enabled picker reuses the current registry and callbacks in a compact icon rail, keeps the -searchable model projection and existing row states on the right, and leaves model-only callers on -their original surface. The separate effort selector remains unchanged. +Provider-enabled picker reuses the current registry in a compact icon rail, keeps the searchable +model projection and existing row states on the right, and leaves model-only callers on their +original surface. The former duplicate `ProviderPicker` was removed. Provider browsing stays local +to the open picker; choosing a foreign model invokes one `onProviderModel(provider, model)` intent, +and `App` creates a fresh session before pinning that Provider and applying its model. The separate +effort selector remains unchanged. + +The popup uses shared menu width, separator, selectable-row, and quiet-fill primitives instead of +one-off picker border, ring, sizing, and selected-state contracts. ## Verification -Verdict: verified. +Verdict: verified The requested T3-style simplification is implemented and the changed behavior is covered by a regression test plus an isolated rendered interaction check. @@ -87,14 +94,19 @@ regression test plus an isolated rendered interaction check. - AC-1: PASS — `bun test tests/sceneChip.test.tsx --test-name-pattern "combines Provider and model"` first failed against the separate Provider button, then passed after `SessionControls` exposed one Provider/model trigger. -- AC-2: PASS — the built-in Browser at `1280x720` showed a `480px`-wide popup fully inside the - viewport, with a `56px` Provider rail, search field, scrollable model list, and selected/default/ - favorite row states. The saved Browser screenshot was compared with the supplied T3 reference - using image inspection. -- AC-3: PASS — `bun test tests/sceneChip.test.tsx --test-name-pattern "combines Provider and model"` - verifies the Provider callback, in-place model replacement, and model callback. The rendered flow - also filtered for `Terra`, switched to Grok without closing, selected `Grok 4.6`, and updated the - trigger without console errors. +- AC-2: PASS — `Browser screenshot inspection` showed the compact Provider rail, search field, scrollable + model list, and selected/default/favorite row states in both the + [dark desktop picker](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-dark.png) + and [light desktop picker](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-light.png). + At the emulated 800 px viewport, the [narrow picker](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-narrow-dark.png) + remained fully inside the viewport (`left 377`, `right 793`, width `416`). +- AC-3: PASS — `bun test tests/sceneChip.test.tsx` confirms Provider browsing invokes neither + legacy session callback, while final selection emits exactly `grok / grok-4.6` through the atomic + callback. `bun test tests/providerModelTransition.test.ts` confirms App creates a fresh draft + before applying the pair, aborts without state mutation when draft creation fails, and skips + creation for an existing blank draft. The rendered flow switched to Grok without closing, + selected `Grok 4.6`, printed the exact `grok:grok-4.6:fresh-session` state, and updated the trigger + without console errors. - AC-4: PASS — `bun test tests/reasoningScaleRendered.test.tsx tests/sceneChip.test.tsx` passed 26 tests and 92 expectations; `bunx tsc --noEmit` and focused ESLint also passed. Model-only picker tests remain in that passing suite. @@ -102,9 +114,10 @@ regression test plus an isolated rendered interaction check. recorded by the final verification pass below. Residual risk: the visual exercise used an isolated renderer fixture rather than starting another -Core process, as required by the repository's single-owner rule. Its `1280x720` viewport is slightly -shorter than the supplied T3 screenshot. T3-only shortcut labels, Legacy models, and global favorite -navigation were intentionally not copied; no backend or provider protocol changed. +Core process, as required by the repository's single-owner rule. The App integration is covered by +the typed callback and fresh-session implementation, while the visual fixture proves the emitted +atomic intent rather than opening a real Provider subprocess. T3-only shortcut labels, Legacy +models, and global favorite navigation were intentionally not copied; no provider protocol changed. ## Review and release From 60140697e1f03ec72c7b553d9b812e417d326f1a Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 04:18:30 +0800 Subject: [PATCH 4/8] fix(desktop): address interaction review regressions --- apps/desktop/src/session/Composer.tsx | 12 +++- apps/desktop/src/session/config.ts | 4 +- apps/desktop/src/sidebar/SessionRail.tsx | 34 ++++++++---- apps/desktop/src/sidebar/sidebarDnd.tsx | 28 ++++++++++ apps/desktop/tests/sceneChip.test.tsx | 55 +++++++++++++++++++ apps/desktop/tests/sidebarDnd.test.ts | 21 +++++++ .../change.md | 29 +++++++--- .../change.md | 24 +++++--- 8 files changed, 177 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/session/Composer.tsx b/apps/desktop/src/session/Composer.tsx index ed018019..444d0179 100644 --- a/apps/desktop/src/session/Composer.tsx +++ b/apps/desktop/src/session/Composer.tsx @@ -1072,8 +1072,18 @@ export function ModelPicker({ disabled={unavailable} className="relative shrink-0" onClick={() => { - setBrowseProvider(candidate.id); setModelSearch(""); + if ( + candidate.id !== provider + && candidate.models.length === 0 + && providerConfig.providersStatus === "ready" + && candidate.available + ) { + providerConfig.onProviderModel(candidate.id, null); + setModelOpen(false); + return; + } + setBrowseProvider(candidate.id); }} > void; - /** A foreign Provider model starts a fresh provider session; never set it on the old session. */ - onProviderModel: (provider: string, model: string) => void; + /** A foreign Provider choice starts a fresh session; null leaves its model unspecified. */ + onProviderModel: (provider: string, model: string | null) => void; onReloadProviders: () => void; /** The engine's two permission axes. Read here, but set only as a pair — see `onSessionMode`. */ mode: PermissionMode; diff --git a/apps/desktop/src/sidebar/SessionRail.tsx b/apps/desktop/src/sidebar/SessionRail.tsx index c01eca35..42839466 100644 --- a/apps/desktop/src/sidebar/SessionRail.tsx +++ b/apps/desktop/src/sidebar/SessionRail.tsx @@ -137,7 +137,9 @@ import { sidebarBeforeIdAtFinalIndex, sidebarDndData, sidebarFinalizedDestination, + sidebarRememberedDragTarget, sidebarSortableSnapshot, + sidebarTaskContainerCollisionPriority, type SidebarDndData, type SidebarDragItem, } from "./sidebarDnd"; @@ -753,10 +755,11 @@ export function SessionRail({ const handleSidebarDragOver = useCallback((event: DragOverEvent) => { const source = sidebarDndData(event.operation.source?.data)?.item; - const target = sidebarDndData(event.operation.target?.data); - if (!source || !target) return; - if (target.item?.kind === source.kind && target.item.id === source.id) return; - dragTargetRef.current = target; + dragTargetRef.current = sidebarRememberedDragTarget( + source ?? null, + event.operation.target?.data, + dragTargetRef.current, + ); }, []); const handleSidebarDragEnd = useCallback((event: DragEndEvent) => { @@ -766,13 +769,18 @@ export function SessionRail({ if (event.canceled) return; const source = sidebarDndData(event.operation.source?.data)?.item; + if (!source) return; const sortable = sidebarSortableSnapshot(event.operation.source); - const eventTarget = sidebarDndData(event.operation.target?.data); - const target = source && eventTarget?.item?.kind === source.kind - && eventTarget.item.id === source.id + const rawEventTarget = sidebarDndData(event.operation.target?.data); + const eventTarget = sidebarRememberedDragTarget( + source, + event.operation.target?.data, + lastTarget, + ); + const target = rawEventTarget?.item?.kind === source.kind + && rawEventTarget.item.id === source.id ? lastTarget - : eventTarget ?? lastTarget; - if (!source) return; + : eventTarget; const finalized = sidebarFinalizedDestination(source, sortable); if (source.kind === "section") { @@ -1506,7 +1514,7 @@ export function SessionRail({ key={project.path} location={{ kind: "tasks", sectionId: null, projectPath: project.path }} accept="task" - collisionPriority={3} + collisionPriority={sidebarTaskContainerCollisionPriority(rows.length > 0)} > {({ ref: taskDropRef, isDropTarget: isTaskDropTarget }) => ( 0 || sectionProjectRows.some( + (project) => (projectRows.get(project.path) ?? []).length > 0, + ), + )} > {({ ref: taskDropRef, isDropTarget: isTaskDropTarget }) => ( ; diff --git a/apps/desktop/tests/sceneChip.test.tsx b/apps/desktop/tests/sceneChip.test.tsx index 10355f94..5365a15a 100644 --- a/apps/desktop/tests/sceneChip.test.tsx +++ b/apps/desktop/tests/sceneChip.test.tsx @@ -295,6 +295,61 @@ describe("SceneChip", () => { rendered.unmount(); }); + test("selects an installed Provider without requiring a preset model", async () => { + activateDom(); + const providerChanges = []; + const providerModelChanges = []; + const rendered = mount( + + providerChanges.push(provider), + onProviderModel: (provider, model) => providerModelChanges.push([provider, model]), + })} + models={[{ id: "gpt-5.6-sol", name: "GPT-5.6-Sol" }]} + currentModel="gpt-5.6-sol" + defaultModel="gpt-5.6-sol" + onModel={() => {}} + configOptions={[]} + onConfigOption={() => {}} + /> + , + ); + + const trigger = rendered.container.querySelector('button[title="Model"]'); + if (!trigger) throw new Error("combined Provider/model trigger did not render"); + click(trigger); + await flush(); + + const picker = dom.document.body.querySelector("[data-provider-model-picker]"); + const pi = picker?.querySelector('button[aria-label="Pi"]'); + if (!pi) throw new Error("Pi Provider rail item did not render"); + click(pi); + await flush(); + + expect(providerChanges).toEqual([]); + expect(providerModelChanges).toEqual([["pi", null]]); + expect(dom.document.body.querySelector("[data-provider-model-picker]")).toBeNull(); + rendered.unmount(); + }); + test("does not duplicate the worktree control when the checkout bar owns it", async () => { activateDom(); const rendered = mount( diff --git a/apps/desktop/tests/sidebarDnd.test.ts b/apps/desktop/tests/sidebarDnd.test.ts index f85f515a..1e2aea15 100644 --- a/apps/desktop/tests/sidebarDnd.test.ts +++ b/apps/desktop/tests/sidebarDnd.test.ts @@ -4,6 +4,8 @@ import { sidebarBeforeIdAtFinalIndex, sidebarFinalizedDestination, sidebarProjectSectionFromGroup, + sidebarRememberedDragTarget, + sidebarTaskContainerCollisionPriority, sidebarTaskLocationFromGroup, } from "../src/sidebar/sidebarDnd"; @@ -54,4 +56,23 @@ describe("sidebar dnd destination mapping", () => { index: 3, }); }); + + test("keeps nonempty Task containers below nested Task rows", () => { + expect(sidebarTaskContainerCollisionPriority(true)).toBeLessThan(2); + expect(sidebarTaskContainerCollisionPriority(false)).toBeGreaterThan(2); + }); + + test("forgets the last destination after leaving or entering an incompatible target", () => { + const source = { kind: "task", id: "task-1" } as const; + const previous = { + item: { kind: "task", id: "task-2" } as const, + location: { kind: "tasks", sectionId: null, projectPath: "/tmp/repo" } as const, + }; + + expect(sidebarRememberedDragTarget(source, null, previous)).toBeNull(); + expect(sidebarRememberedDragTarget(source, { + item: { kind: "project", id: "/tmp/other" }, + location: { kind: "projects", sectionId: null }, + }, previous)).toBeNull(); + }); }); diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md index 35e7b05f..85d807b4 100644 --- a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md +++ b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md @@ -45,7 +45,8 @@ operations. The implementation must not depend on native HTML5 `draggable`, `dra - [x] AC-2: A physical pointer drag moves a Project into a user Section and back to the root list, including a Section without existing Projects. - [x] AC-3: Section and Task drag targets continue to map to the existing move operations, with - keyboard drag support supplied by the library. + keyboard drag support supplied by the library. Nested Task rows outrank their nonempty + container, and dropping outside a compatible target cancels instead of reusing stale hover. - [x] AC-4: Focused rendered tests, type checks, renderer build, lifecycle checks, and an isolated real rendered-window inspection pass. @@ -80,10 +81,17 @@ a stale hover row. Group components are URI-encoded, so paths and Section IDs co remain unambiguous. Empty Section and empty root drop zones have distinct IDs and explicit collision priority, so another item kind cannot overwrite the registered target. +Nonempty Project and Section Task containers stay below their nested Task rows in collision +priority while empty containers retain the stronger target needed for first-item placement. +Drag-over normalization also clears the remembered destination on a missing or incompatible +target, so a release outside the sidebar cannot mutate the last valid destination. + ## Verification Verdict: verified +Review-feedback corrections are included in this verdict. + ### Acceptance evidence - AC-1: PASS — `Browser physical pointer drag` used a paced Chromium pointer path in the isolated rendered desktop shell @@ -97,11 +105,12 @@ Verdict: verified also moved `codeTwo` into the empty `Work` Section. - AC-3: PASS — `bun test tests/sessionRailRendered.test.tsx` verifies that Section, Project, and Task rows expose dedicated dnd-kit keyboard handles and no native `[draggable=true]` elements. - `bun test tests/sidebarDnd.test.ts tests/sidebarProjects.test.ts tests/sidebarSections.test.ts` - verifies finalized index mapping plus Section, Project, and Task destination decoding, including - encoded paths. Existing typed domain move suites pass. -- AC-4: PASS — `bun test` plus TypeScript, focused suites, and the renderer production - build passed in the final verification pass. The isolated rendered pass covered same-list + `bun test tests/sidebarDnd.test.ts` additionally verifies that nonempty Task containers stay + below Task-row priority and that null or incompatible drag targets clear the last destination. + The broader destination suites verify finalized index mapping plus Section, Project, and Task + decoding, including encoded paths. Existing typed domain move suites pass. +- AC-4: PASS — the final `bun test` passed 809 tests and 3,841 expectations; `bunx tsc --noEmit` + and `bun run build` also passed. The isolated rendered pass covered same-list pointer sorting, empty-Section keyboard placement, the [narrow shell](evidence/pr-review-narrow-dark.png), and the final visual state without starting a second Core process. @@ -109,7 +118,8 @@ The initial native-HTML5 baseline emitted `pointerdown` but not `dragstart`. Dur the first library pass exposed two integration defects: optimistic same-list sorting reported the source row as the final target, and Project/Task empty drop zones shared an ID. The final adapter uses sortable destination metadata and kind-qualified drop-zone IDs; both failure paths were -retested after correction. +retested after correction. The later review regressions were first reproduced by focused failing +tests, then passed after container-priority and stale-target normalization were corrected. Residual risk: the user's live Core-backed profile was deliberately not opened because another process owns it. Pointer and keyboard gestures were exercised in the isolated Chromium renderer; @@ -130,5 +140,6 @@ Preparing this section does not authorize merge, deployment, release, or product ## Feedback -The screenshot and request are the direct defect feedback for this change. No post-change feedback -exists yet. +The screenshot and request are the direct defect feedback for this change. PR review later found +that nonempty Project/Section container zones could outrank nested Task rows and that leaving all +valid targets retained the last hover destination. The user explicitly requested both corrections. diff --git a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md index 55ee58a7..7d5a5bc5 100644 --- a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md +++ b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md @@ -36,6 +36,8 @@ adapter-owned model options, disabled states, and existing callbacks. Provider l unavailable states remain legible. Choosing a Provider in the rail browses its models locally and keeps the surface open. Selecting a model owned by another Provider applies Provider and model atomically to a fresh session; it must never call `setModel` on the old Provider session. +An installed Provider with an empty pre-session model catalogue is selected atomically with its +model left unspecified; the picker must not invent or persist a preset model. Do not copy T3-only shortcut labels, legacy sections, or new favorite semantics. Keep reasoning effort independent because it is a separate provider-owned configuration axis. ModelPicker uses in @@ -48,7 +50,8 @@ Side Chat and Project Settings remain model-only and retain their current compac - [x] AC-2: The unified popup has a Provider icon rail, model search, and a scrollable model list whose selected/favorite/default states reuse existing behavior. - [x] AC-3: Switching the rail reveals that Provider's models without mutating the current session; - selecting a foreign model starts a fresh session and applies Provider/model atomically. + selecting a foreign model starts a fresh session and applies Provider/model atomically. An + installed Provider with no catalogue is selectable with `model = null`. - [x] AC-4: Reasoning effort, disabled/running behavior, provider loading/error states, Side Chat, and Project Settings model-only use remain intact. - [x] AC-5: Focused/full tests, renderer build, rendered Browser interaction and reference-image @@ -77,7 +80,9 @@ model projection and existing row states on the right, and leaves model-only cal original surface. The former duplicate `ProviderPicker` was removed. Provider browsing stays local to the open picker; choosing a foreign model invokes one `onProviderModel(provider, model)` intent, and `App` creates a fresh session before pinning that Provider and applying its model. The separate -effort selector remains unchanged. +effort selector remains unchanged. For an available Provider whose catalogue is empty, the same +fresh-session intent carries `model = null`, closes the picker, and leaves model resolution to that +Provider instead of manufacturing a default. The popup uses shared menu width, separator, selectable-row, and quiet-fill primitives instead of one-off picker border, ring, sizing, and selected-state contracts. @@ -86,6 +91,8 @@ one-off picker border, ring, sizing, and selected-state contracts. Verdict: verified +Review-feedback corrections are included in this verdict. + The requested T3-style simplification is implemented and the changed behavior is covered by a regression test plus an isolated rendered interaction check. @@ -106,12 +113,14 @@ regression test plus an isolated rendered interaction check. before applying the pair, aborts without state mutation when draft creation fails, and skips creation for an existing blank draft. The rendered flow switched to Grok without closing, selected `Grok 4.6`, printed the exact `grok:grok-4.6:fresh-session` state, and updated the trigger - without console errors. + without console errors. The added empty-catalogue regression emits exactly `pi / null`; an + isolated Browser interaction then showed `Provider: pi`, `Model: 未指定`, and a closed picker. - AC-4: PASS — `bun test tests/reasoningScaleRendered.test.tsx tests/sceneChip.test.tsx` passed 26 tests and 92 expectations; `bunx tsc --noEmit` and focused ESLint also passed. Model-only picker tests remain in that passing suite. -- AC-5: PASS — `bun test` and `bun run build:renderer` passed. Repository lifecycle commands are - recorded by the final verification pass below. +- AC-5: PASS — the final `bun test` passed 809 tests and 3,841 expectations; `bun run build` + completed the production renderer and native package. Repository lifecycle commands are recorded + by the final verification pass below. Residual risk: the visual exercise used an isolated renderer fixture rather than starting another Core process, as required by the repository's single-owner rule. The App integration is covered by @@ -131,5 +140,6 @@ No release: PR creation is authorized; merge, deployment, and release remain una ## Feedback -The T3 and CodeTwo screenshots are the accepted reference and current-state evidence; no post-change -feedback exists yet. +The T3 and CodeTwo screenshots are the accepted reference and current-state evidence. PR review +found that a Provider with no pre-session model catalogue had no selection path. The user clarified +that such Providers should be selectable directly and do not require any preset model. From 220af2a5c8b890b53b6a773b8239de5383a4beff Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 04:53:01 +0800 Subject: [PATCH 5/8] refactor(desktop): give model picker a CodeTwo layout --- apps/desktop/src/session/Composer.tsx | 103 +++++++++--------- apps/desktop/tests/sceneChip.test.tsx | 12 +- .../change.md | 68 ++++++++---- 3 files changed, 103 insertions(+), 80 deletions(-) diff --git a/apps/desktop/src/session/Composer.tsx b/apps/desktop/src/session/Composer.tsx index 444d0179..d0fdabe5 100644 --- a/apps/desktop/src/session/Composer.tsx +++ b/apps/desktop/src/session/Composer.tsx @@ -824,7 +824,7 @@ export function ModelPicker({ const [effortOpen, setEffortOpen] = useState(false); const [browseProvider, setBrowseProvider] = useState(provider); const modelTriggerRef = useRef(null); - const providerRailEnabled = providerConfig !== undefined; + const providerSwitcherEnabled = providerConfig !== undefined; const providerRegistry = providerConfig ? providerConfig.providers.length > 0 ? providerConfig.providers : fallbackProviders() : []; @@ -833,7 +833,7 @@ export function ModelPicker({ (candidate) => candidate.enabled !== false || candidate.id === providerConfig.provider, ) : []; - const pickerProvider = providerRailEnabled ? browseProvider : provider; + const pickerProvider = providerSwitcherEnabled ? browseProvider : provider; const pickerProviderInfo = providerChoices.find((candidate) => candidate.id === pickerProvider); const browsingCurrentProvider = pickerProvider === provider; const pickerModels = browsingCurrentProvider ? models : pickerProviderInfo?.models ?? []; @@ -861,7 +861,7 @@ export function ModelPicker({ if (!modelOpen) setBrowseProvider(provider); }, [modelOpen, provider]); useEffect(() => { - if (!providerRailEnabled) return; + if (!providerSwitcherEnabled) return; const openProviderModelPicker = () => { setBrowseProvider(provider); setModelOpen(true); @@ -869,8 +869,8 @@ export function ModelPicker({ }; window.addEventListener("codetwo-open-provider-picker", openProviderModelPicker); return () => window.removeEventListener("codetwo-open-provider-picker", openProviderModelPicker); - }, [provider, providerRailEnabled]); - if (!providerRailEnabled && !hasSession && models.length === 0 && !showWhenUnavailable) return null; + }, [provider, providerSwitcherEnabled]); + if (!providerSwitcherEnabled && !hasSession && models.length === 0 && !showWhenUnavailable) return null; const effortName = (e: Effort | null) => (e ? t(`effort.${e}` as "effort.low") : t("composer.default")); @@ -1041,72 +1041,67 @@ export function ModelPicker({ side="top" className={cn( "flex max-h-(--available-height) overflow-hidden", - providerRailEnabled - ? "w-menu-wide max-w-(--available-width) flex-row p-0" + providerSwitcherEnabled + ? "w-menu-wide max-w-(--available-width) flex-col p-0" : "w-64 flex-col p-1.5", )} > {providerConfig ? ( -

+
{providerChoices.map((candidate) => { const selected = candidate.id === pickerProvider; const unavailable = providerConfig.providersStatus === "ready" && !candidate.available; return ( - - { - setModelSearch(""); - if ( - candidate.id !== provider - && candidate.models.length === 0 - && providerConfig.providersStatus === "ready" - && candidate.available - ) { - providerConfig.onProviderModel(candidate.id, null); - setModelOpen(false); - return; - } - setBrowseProvider(candidate.id); - }} - > - -
- -
+ +
{providerConfig.providersStatus === "loading" ? (

{t("config.providersLoading")} diff --git a/apps/desktop/tests/sceneChip.test.tsx b/apps/desktop/tests/sceneChip.test.tsx index 5365a15a..000aa626 100644 --- a/apps/desktop/tests/sceneChip.test.tsx +++ b/apps/desktop/tests/sceneChip.test.tsx @@ -221,7 +221,7 @@ describe("SceneChip", () => { rendered.unmount(); }); - test("combines Provider and model selection into one T3-style picker", async () => { + test("combines Provider and model selection into one CodeTwo picker", async () => { activateDom(); const providers = [ { @@ -275,10 +275,14 @@ describe("SceneChip", () => { const picker = dom.document.body.querySelector("[data-provider-model-picker]"); expect(picker).toBeTruthy(); - expect(picker?.querySelector("[data-provider-rail]")).toBeTruthy(); + expect(picker?.querySelector("[data-provider-rail]")).toBeNull(); + const providerSwitcher = picker?.querySelector("[data-provider-switcher]"); + expect(providerSwitcher).toBeTruthy(); + expect(providerSwitcher?.textContent).toContain("OpenAI Codex"); + expect(providerSwitcher?.textContent).toContain("Grok"); expect(picker?.querySelector('input[aria-label="Search models"]')).toBeTruthy(); const grok = picker?.querySelector('button[aria-label="Grok"]'); - if (!grok) throw new Error("Grok Provider rail item did not render"); + if (!grok) throw new Error("Grok Provider switcher item did not render"); click(grok); await flush(); @@ -340,7 +344,7 @@ describe("SceneChip", () => { const picker = dom.document.body.querySelector("[data-provider-model-picker]"); const pi = picker?.querySelector('button[aria-label="Pi"]'); - if (!pi) throw new Error("Pi Provider rail item did not render"); + if (!pi) throw new Error("Pi Provider switcher item did not render"); click(pi); await flush(); diff --git a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md index 7d5a5bc5..e578bc0e 100644 --- a/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md +++ b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md @@ -11,7 +11,7 @@ created: 2026-08-31 updated: 2026-09-01 source: direct user request with T3 model-picker reference and current CodeTwo screenshot inputs: current Provider registry, provider-owned model choices, model favorites and preferences -outputs: one Composer model entry with Provider rail, search, and model selection +outputs: one Composer model entry with a CodeTwo Provider switcher, search, and model selection scope: apps/desktop/src/App.tsx, apps/desktop/src/session/Composer.tsx, apps/desktop/src/session/config.ts, apps/desktop/tests/checkoutPickerRendered.test.tsx, apps/desktop/tests/providerModelTransition.test.ts, apps/desktop/tests/sceneChip.test.tsx, docs/sdlc/changes/2026-08-31-simplify-provider-model-picker next_trigger: human review and merge decision on PR #208 verification_mode: owner @@ -30,10 +30,11 @@ model, opening one surface where the user can switch Provider, search models, an ## Spec In the primary Composer controls, remove the separate visible Provider chip. Extend the existing -model picker with a narrow Provider icon rail and keep the searchable model list beside it. Reuse -the current Provider registry, ProviderIcon, model grouping, favorites, hidden-model preferences, +model picker with a compact horizontal Provider switcher above the searchable model list. Show +Provider icons and names instead of copying T3's permanent left icon rail. Reuse the current +Provider registry, ProviderIcon, model grouping, favorites, hidden-model preferences, adapter-owned model options, disabled states, and existing callbacks. Provider loading, retry, and -unavailable states remain legible. Choosing a Provider in the rail browses its models locally and +unavailable states remain legible. Choosing a Provider in the switcher browses its models locally and keeps the surface open. Selecting a model owned by another Provider applies Provider and model atomically to a fresh session; it must never call `setModel` on the old Provider session. An installed Provider with an empty pre-session model catalogue is selected atomically with its @@ -47,9 +48,10 @@ Side Chat and Project Settings remain model-only and retain their current compac - [x] AC-1: Primary Composer controls show one Provider/model trigger instead of separate Provider and model chips. -- [x] AC-2: The unified popup has a Provider icon rail, model search, and a scrollable model list - whose selected/favorite/default states reuse existing behavior. -- [x] AC-3: Switching the rail reveals that Provider's models without mutating the current session; +- [x] AC-2: The unified popup has a horizontally scrollable Provider switcher with icon, name, and + availability, plus model search and a scrollable model list whose selected/favorite/default + states reuse existing behavior. +- [x] AC-3: Switching the Provider row reveals that Provider's models without mutating the current session; selecting a foreign model starts a fresh session and applies Provider/model atomically. An installed Provider with no catalogue is selectable with `model = null`. - [x] AC-4: Reasoning effort, disabled/running behavior, provider loading/error states, Side Chat, @@ -66,18 +68,20 @@ external mutation remain unauthorized. ## Plan 1. Add a focused SessionControls regression for one trigger and Provider-to-model selection. -2. Extend ModelPicker with an optional Provider rail while preserving model-only callers. -3. Render an isolated realistic provider/model fixture and compare it with the T3 reference. +2. Extend ModelPicker with an optional Provider switcher while preserving model-only callers. +3. Render an isolated realistic provider/model fixture in dark, light, and 800 px states; compare + it with the T3 reference to confirm CodeTwo keeps a distinct hierarchy and component language. Rollback restores the separate ProviderPicker render in SessionControls and removes the optional -Provider rail from ModelPicker. +Provider switcher from ModelPicker. ## Build `SessionControls` now renders one `ModelPicker` entry for Provider and model selection. The -Provider-enabled picker reuses the current registry in a compact icon rail, keeps the searchable -model projection and existing row states on the right, and leaves model-only callers on their -original surface. The former duplicate `ProviderPicker` was removed. Provider browsing stays local +Provider-enabled picker reuses the current registry in a compact horizontal switcher with visible +Provider names, icons, availability dots, and a quiet selected state. The switcher scrolls without +exposing a heavy scrollbar, keeps the searchable model projection below it, and leaves model-only +callers on their original surface. The former duplicate `ProviderPicker` was removed. Provider browsing stays local to the open picker; choosing a foreign model invokes one `onProviderModel(provider, model)` intent, and `App` creates a fresh session before pinning that Provider and applying its model. The separate effort selector remains unchanged. For an available Provider whose catalogue is empty, the same @@ -93,20 +97,24 @@ Verdict: verified Review-feedback corrections are included in this verdict. -The requested T3-style simplification is implemented and the changed behavior is covered by a -regression test plus an isolated rendered interaction check. +The requested simplification now uses a CodeTwo-specific hierarchy rather than copying T3's left +rail, and the changed behavior is covered by a regression test plus isolated rendered interaction +checks. ### Acceptance evidence - AC-1: PASS — `bun test tests/sceneChip.test.tsx --test-name-pattern "combines Provider and model"` first failed against the separate Provider button, then passed after `SessionControls` exposed one Provider/model trigger. -- AC-2: PASS — `Browser screenshot inspection` showed the compact Provider rail, search field, scrollable - model list, and selected/default/favorite row states in both the - [dark desktop picker](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-dark.png) - and [light desktop picker](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-light.png). - At the emulated 800 px viewport, the [narrow picker](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-narrow-dark.png) - remained fully inside the viewport (`left 377`, `right 793`, width `416`). +- AC-2: PASS — the initial [dark rail baseline](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-dark.png), + [light rail baseline](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-light.png), + and [800 px rail baseline](../2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-narrow-dark.png) + record the layout replaced by this feedback. The final Browser pass inspected the new horizontal + Provider switcher, search field, scrollable model list, and selected/default/favorite row states + in dark, light, and an 800 px iframe viewport: the popup remained fully visible, long Provider + sets scrolled horizontally, and no + scrollbar or clipped primary content remained. Direct `view_image` comparison confirmed that + the T3 vertical rail, shortcut badges, and legacy section were not copied. - AC-3: PASS — `bun test tests/sceneChip.test.tsx` confirms Provider browsing invokes neither legacy session callback, while final selection emits exactly `grok / grok-4.6` through the atomic callback. `bun test tests/providerModelTransition.test.ts` confirms App creates a fresh draft @@ -118,10 +126,24 @@ regression test plus an isolated rendered interaction check. - AC-4: PASS — `bun test tests/reasoningScaleRendered.test.tsx tests/sceneChip.test.tsx` passed 26 tests and 92 expectations; `bunx tsc --noEmit` and focused ESLint also passed. Model-only picker tests remain in that passing suite. -- AC-5: PASS — the final `bun test` passed 809 tests and 3,841 expectations; `bun run build` +- AC-5: PASS — the final `bun test` passed 809 tests and 3,867 expectations; `bun run build` completed the production renderer and native package. Repository lifecycle commands are recorded by the final verification pass below. +### Visual comparison ledger + +- Information architecture: T3's permanent vertical icon rail became a CodeTwo horizontal + Provider switcher above the model content. +- Readability: icon-only navigation became icon plus Provider name, while availability remains a + small semantic status dot. +- Density: the existing `w-menu-wide` popup, shared compact controls, and model-row rhythm remain; + no shortcut badges, Legacy section, or second navigation column were added. +- Palette and typography: all surfaces, selected states, text sizes, radii, separators, and shadows + use existing CodeTwo tokens and primitives in both dark and light themes. +- Responsive behavior: the Provider switcher scrolls horizontally without a visible scrollbar, + and the full model workflow remained visible in the 800 px rendered state. +- Visible-copy diff: no new heading, category label, badge, or explanatory copy was introduced. + Residual risk: the visual exercise used an isolated renderer fixture rather than starting another Core process, as required by the repository's single-owner rule. The App integration is covered by the typed callback and fresh-session implementation, while the visual fixture proves the emitted @@ -143,3 +165,5 @@ No release: PR creation is authorized; merge, deployment, and release remain una The T3 and CodeTwo screenshots are the accepted reference and current-state evidence. PR review found that a Provider with no pre-session model catalogue had no selection path. The user clarified that such Providers should be selectable directly and do not require any preset model. +After the reviewed fixes, the user requested a more polished UI that keeps the simplified unified +flow without copying T3's permanent left icon rail. From df84ce7e6672672f44a2a17fd139942cd919f863 Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 05:06:48 +0800 Subject: [PATCH 6/8] fix(desktop): shorten Codex provider label --- apps/desktop/src/session/Composer.tsx | 5 +++-- apps/desktop/tests/sceneChip.test.tsx | 3 ++- .../change.md | 14 +++++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/session/Composer.tsx b/apps/desktop/src/session/Composer.tsx index d0fdabe5..1fd7100b 100644 --- a/apps/desktop/src/session/Composer.tsx +++ b/apps/desktop/src/session/Composer.tsx @@ -1057,6 +1057,7 @@ export function ModelPicker({ {providerChoices.map((candidate) => { const selected = candidate.id === pickerProvider; const unavailable = providerConfig.providersStatus === "ready" && !candidate.available; + const displayName = candidate.id === "codex" ? "Codex" : candidate.display_name; return (