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..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, @@ -1401,17 +1404,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 +2643,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) ?? @@ -7299,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(() => {}); }, @@ -7947,12 +7950,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 +8322,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/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/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..1fd7100b 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,8 +63,8 @@ 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 { 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[]; @@ -772,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. @@ -904,6 +799,7 @@ export function ModelPicker({ configOptions, onConfigOption, hasSession, + providerConfig, showWhenUnavailable = false, disabled = false, }: { @@ -915,6 +811,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 +822,67 @@ 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 providerSwitcherEnabled = 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 = providerSwitcherEnabled ? 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 selectPickerModel = (id: string) => { + if (providerConfig && pickerProvider !== provider) { + providerConfig.onProviderModel(pickerProvider, id); + return; + } + onModel(id); + }; 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 (!providerSwitcherEnabled) 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, 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")); 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 +890,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: () => selectPickerModel( + pickVariant(f, pickerActiveVariant?.effort ?? null, pickerDefaultModel).id, + ), })); } @@ -1022,6 +969,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 +1022,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; + const displayName = candidate.id === "codex" ? "Codex" : candidate.display_name; + return ( + + ); + })} +

+ +
+ {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 +1211,6 @@ export function SessionControls({
{config.scenesEnabled ? : null} - @@ -1250,8 +1285,6 @@ export function Composer({ hero, docMode, onDocMode, - height, - onHeight, boundsRef, models, currentModel, @@ -1344,16 +1377,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 +1640,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/session/config.ts b/apps/desktop/src/session/config.ts index 28732134..3a98719d 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 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 49c6887f..42839466 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 { + DragDropRoot, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from "@/components/ui/drag-drop"; 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,33 @@ 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, + sidebarBeforeIdAtFinalIndex, + sidebarDndData, + sidebarFinalizedDestination, + sidebarRememberedDragTarget, + sidebarSortableSnapshot, + sidebarTaskContainerCollisionPriority, + 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 +356,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 +747,146 @@ 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; + dragTargetRef.current = sidebarRememberedDragTarget( + source ?? null, + event.operation.target?.data, + dragTargetRef.current, + ); + }, []); + + 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; + if (!source) return; + const sortable = sidebarSortableSnapshot(event.operation.source); + 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; + 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), + ); + } else if (target.location.kind === "sections") { + setTaskSections((current) => moveSidebarTaskSection(current, source.id, null)); + } + return; + } + + if (source.kind === "project") { + if (finalized?.kind === "projects") { + dropProject( + source.id, + 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") { + 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; + } + + 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; + 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, + taskSections.sections, + ]); + /** 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 +904,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 +947,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 +1129,28 @@ export function SessionRail({ }; return ( - + {({ ref: sortableRef, handleRef, sourceRef, targetRef, isDragging, isDropTarget }) => ( + { + sortableRef(element); + targetRef?.(element); + }} data-session-id={s.id} - data-sidebar-dragging={dragItem?.kind === "task" && dragItem.id === s.id ? "true" : undefined} + data-sidebar-dragging={isDragging ? "true" : undefined} + data-sidebar-drop-target={isDropTarget ? "true" : undefined} data-session-density="compact" - draggable={!isArchived && renaming?.id !== s.id} - onDragStart={(event) => { - 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 +1170,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 +1506,73 @@ 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 ( - 0)} + > + {({ ref: taskDropRef, isDropTarget: isTaskDropTarget }) => ( + + {({ 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} + > + 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 +1632,11 @@ export function SessionRail({ )} +
+ )} + + )} + ); }; @@ -1519,8 +1655,41 @@ export function SessionRail({ await Promise.all(tasks.map((row) => Promise.resolve(onArchive(row.id, true)))); }; return ( - + {({ ref: projectDropRef, isDropTarget: isProjectDropTarget }) => ( + 0 || sectionProjectRows.some( + (project) => (projectRows.get(project.path) ?? []).length > 0, + ), + )} + > + {({ ref: taskDropRef, isDropTarget: isTaskDropTarget }) => ( + + {({ 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} + > + setTaskSections((current) => @@ -1529,47 +1698,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 ? ( @@ -1596,19 +1729,30 @@ 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 +1833,13 @@ export function SessionRail({ )} +
+ )} + + )} + + )} + ); }; @@ -1726,6 +1855,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 +1902,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 +2023,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 +2077,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 +2171,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..e20afa5f --- /dev/null +++ b/apps/desktop/src/sidebar/sidebarDnd.tsx @@ -0,0 +1,245 @@ +import type { ReactNode } from "react"; +import { + useDragDropSortable, + useDragDropZone, +} from "@/components/ui/drag-drop"; + +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; +} + +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; + 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:${encodeURIComponent(location.sectionId ?? "")}`; + } + if (item.kind === "task" && location.kind === "tasks") { + return `sidebar-tasks:${encodeURIComponent(location.sectionId ?? "")}:${encodeURIComponent(location.projectPath ?? "")}`; + } + 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 = useDragDropSortable({ + id: dndId("item", item, location), + index, + group: dndGroup(item, location), + type: item.kind, + accept, + collisionPriority, + disabled, + data: { item, location }, + }); + + return children({ + ref: sortable.ref, + handleRef: sortable.handleRef, + 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 = useDragDropZone({ + 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 sidebarTaskContainerCollisionPriority(hasNestedTaskRows: boolean): number { + return hasNestedTaskRows ? 1 : 3; +} + +function sidebarDragTargetIsCompatible( + source: SidebarDragItem, + target: SidebarDndData, +): boolean { + if (source.kind === "section") { + return target.location.kind === "sections" || target.item?.kind === "section"; + } + if (source.kind === "project") { + return target.location.kind === "projects" || target.location.kind === "section"; + } + return target.location.kind === "tasks" || target.location.kind === "section"; +} + +export function sidebarRememberedDragTarget( + source: SidebarDragItem | null, + value: unknown, + previous: SidebarDndData | null, +): SidebarDndData | null { + const target = sidebarDndData(value); + if (!source || !target || !sidebarDragTargetIsCompatible(source, target)) return null; + if (target.item?.kind === source.kind && target.item.id === source.id) return previous; + return target; +} + +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 { + 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 = 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/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/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/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( { + 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 9643ce19..a785395b 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 { @@ -214,6 +221,140 @@ describe("SceneChip", () => { rendered.unmount(); }); + test("combines Provider and model selection into one CodeTwo 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 providerModelChanges = []; + const rendered = mount( + + providerChanges.push(provider), + onProviderModel: (provider, model) => providerModelChanges.push([provider, model]), + })} + 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]")).toBeNull(); + const providerSwitcher = picker?.querySelector("[data-provider-switcher]"); + expect(providerSwitcher).toBeTruthy(); + expect(providerSwitcher?.textContent).toContain("Codex"); + expect(providerSwitcher?.textContent).not.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 switcher item did not render"); + click(grok); + await flush(); + + 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([]); + expect(providerModelChanges).toEqual([["grok", "grok-4.6"]]); + + 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 switcher 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/sessionRailRendered.test.tsx b/apps/desktop/tests/sessionRailRendered.test.tsx index e40dfdc7..c54cbc04 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 secondHandle = view.container.querySelector( + '[data-session-id="second"] [data-session-drag-handle]', ); - 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 (!secondHandle) 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 repoHandle = view.container.querySelector( + '[data-project-drag-handle="/tmp/repo"]', ); + if (!repoHandle) 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(repoHandle.getAttribute("aria-roledescription")).toBe("draggable"); + expect(secondHandle.getAttribute("aria-roledescription")).toBe("draggable"); }); - expect(view.container.querySelector( - '[data-session-id="second"] [data-session-drag-handle]', - )?.getAttribute("draggable")).toBe("true"); + expect(repoHandle.getAttribute("draggable")).toBeNull(); + expect(secondHandle.getAttribute("draggable")).toBeNull(); + expect(view.container.querySelector("[draggable=true]")).toBeNull(); + const description = dom.document.getElementById( + repoHandle.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/apps/desktop/tests/sidebarDnd.test.ts b/apps/desktop/tests/sidebarDnd.test.ts new file mode 100644 index 00000000..1e2aea15 --- /dev/null +++ b/apps/desktop/tests/sidebarDnd.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; + +import { + sidebarBeforeIdAtFinalIndex, + sidebarFinalizedDestination, + sidebarProjectSectionFromGroup, + sidebarRememberedDragTarget, + sidebarTaskContainerCollisionPriority, + 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, + }); + }); + + 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/crates/core/src/store.rs b/crates/core/src/store.rs index af43ff1a..e1c84708 100644 --- a/crates/core/src/store.rs +++ b/crates/core/src/store.rs @@ -1931,39 +1931,71 @@ 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( - "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 json_extract(q.part_json,'$.kind') IN ('text','prompt') - )", + "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) } @@ -4220,7 +4252,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(); @@ -4239,7 +4271,27 @@ 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(); + // 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(); @@ -4262,7 +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. + // 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"); } @@ -4389,6 +4442,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 +4462,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..070feb74 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-align-environment-popover/change.md @@ -0,0 +1,106 @@ +--- +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-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: human review and merge decision on PR #208 +verification_mode: owner +verified_by: codex +verified_at: 2026-09-01 +--- + +# 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"`. 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; 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. + +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 #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. +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..74b1a8d6 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-align-sidebar-search-shortcut/change.md @@ -0,0 +1,103 @@ +--- +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-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: human review and merge decision on PR #208 +verification_mode: owner +verified_by: codex +verified_at: 2026-09-01 +--- + +# 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. 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. +- 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 #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. +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..e7d2401f --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-animate-pane-splits/change.md @@ -0,0 +1,116 @@ +--- +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-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: human review and merge decision on PR #208 +verification_mode: owner +verified_by: codex +verified_at: 2026-09-01 +--- + +# 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`. 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. +- 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; 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. + +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 #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. +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..f38f4744 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-remove-composer-resize-grip/change.md @@ -0,0 +1,111 @@ +--- +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-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: human review and merge decision on PR #208 +verification_mode: owner +verified_by: codex +verified_at: 2026-09-01 +--- + +# 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. 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 — 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. + +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 #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. +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..8d98b5f9 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-remove-project-disclosure-icon/change.md @@ -0,0 +1,106 @@ +--- +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-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: human review and merge decision on PR #208 +verification_mode: owner +verified_by: codex +verified_at: 2026-09-01 +--- + +# 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. 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 — 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 + 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 #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. +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..85d807b4 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/change.md @@ -0,0 +1,145 @@ +--- +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-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/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-09-01 +--- + +# 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. 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. + +## 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 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 + 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 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 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 +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. + +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 + 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 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` 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. + +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. 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; +the unchanged Core persistence operations are covered by domain tests rather than a second live +Core instance. + +## Review and release + +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. +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. 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-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 00000000..c74afe15 Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/drag-result-dark.png differ 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 00000000..7cf10911 Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/environment-light.png differ 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 00000000..e1db53ce Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-dark.png differ diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-light.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-light.png new file mode 100644 index 00000000..9a34ae37 Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-light.png differ 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 00000000..35c9f6d5 Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/model-picker-narrow-dark.png differ diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-dark.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-dark.png new file mode 100644 index 00000000..bb00b7d5 Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-dark.png differ diff --git a/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-narrow-dark.png b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-narrow-dark.png new file mode 100644 index 00000000..924795c5 Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/pr-review-narrow-dark.png differ 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 00000000..6b231661 Binary files /dev/null and b/docs/sdlc/changes/2026-08-31-replace-sidebar-drag-with-dnd-kit/evidence/split-animation-end-light.png differ 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..4e04b577 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-restore-sidebar-session-summary/change.md @@ -0,0 +1,114 @@ +--- +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-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: human review and merge decision on PR #208 +verification_mode: owner +verified_by: codex +verified_at: 2026-09-01 +--- + +# 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 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 + +- [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 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 + +### 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 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 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 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. + +## Review and release + +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. +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..43409e5e --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-simplify-provider-model-picker/change.md @@ -0,0 +1,186 @@ +--- +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-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 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 +verified_by: codex +verified_at: 2026-09-01 +--- + +# 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 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 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 +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 +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 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, + 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 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 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 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 +fresh-session intent carries `model = null`, closes the picker, and leaves model resolution to that +Provider instead of manufacturing a default. + +The compact switcher renders the canonical `codex` Provider as `Codex`; registry names, +configuration surfaces, Provider IDs, protocols, and persisted session state remain 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 + +Review-feedback corrections are included in this verdict. + +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 — 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. A final rendered + Browser check confirmed the compact switcher exposes `Codex` visually and through its accessible + label, with zero remaining `OpenAI Codex` labels; the screenshot is + `/tmp/codetwo-provider-picker-codex-label.png`. That label-only fixture contained two placeholder + models and is not model-catalogue completeness evidence. A live `codex app-server` `model/list` + query and the existing Core catalogue test confirm all seven current families; the frontend + projects the Provider-owned list without maintaining a second hard-coded catalogue. +- 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. 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 — the final `bun test` passed 809 tests and 3,853 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: the compact Provider label was shortened from `OpenAI Codex` to the requested + `Codex`; 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 +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 + +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. +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. 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. +The user then requested the compact switcher label `OpenAI Codex` be shortened to `Codex`. +After reviewing that screenshot, the user noted that the model list appeared incomplete. Diagnosis +confirmed the screenshot fixture contained only two placeholder model families while the current +Codex catalogue exposes seven; the acceptance evidence must use the complete catalogue. +At the user's request to apply Ponytail, the duplicated seven-model frontend fixture was removed; +the existing Core catalogue contract and the Provider-owned frontend projection are the smaller +durable seams.