v7.0.0 — Forge
[7.0.0] Forge
Forge brings manual task reordering across the full stack — a new TaskService.ReorderTasks RPC backs Shift+↑/↓ in the TUI and @dnd-kit-powered drag-and-drop in the GUI, replacing the silent task-number-descending sort with the spec'd (position ASC, task_number ASC) order, and a new-task-defaults-to-bottom rule so manual orderings survive task creation. The GUI chat terminal stops snapping to byte 0 mid-scroll thanks to a daemon-side cursor on SubscribeRawOutput and an idempotent client-side subscription effect. The Open-in-IDE menu now finds CLIs installed outside the GUI's stripped-down PATH (code at /usr/local/bin/code, Homebrew shims at /opt/homebrew/bin) so VS Code, Cursor, Windsurf, Zed, Sublime, and the JetBrains shims launch from the GUI even when started from Finder or the Dock.
Added
- TUI manual task reorder via
Shift+↑/↓(#0098).Shift+↑andShift+↓on a focused active row swap the selected task with its in-bounds same-status neighbour and fireTaskService.ReorderTasks(the v7 RPC landed in #0097) with the project's full new active task-number ordering. The chord lands intaskListKeys.MoveUp/MoveDown(internal/tui/keys.go) bound exclusively toshift+up/shift+down—K/Jletter chords were dropped after auditingkeys.goand finding lowercasej/kalready in use for navigation in the task list, log viewer, definition view, settings, exporter, integrations overlay, branches overlay, fleet/project insights overlay, and global settings; uppercase variants would have read as "vim chord" but in practice fire on any user holding Shift while repeatingj. The terminal handler's pre-existingshift+up/shift+downline-scroll case (internal/tui/keyhandler.go:519) sits insidehandleTerminalKeywhich only runs whenfocusedPanel == 1, so the left-panel dispatch inhandleTaskListKeycannot collide. NewModel.moveSelectedTask(direction int)ininternal/tui/actions.goderives a Draft → Ready → Done flat list of active tasks viaactiveTasksInDisplayOrder(filtersGetDeletedAt() != nil, preserves the canonical position-then-task_number order already produced by the server's #0096 sort), locates the focused row, callsreorderActiveTasksto swap against the same-status neighbour, and returns(nil, false)for cross-section moves and top/bottom boundaries — both are silent no-ops with no toast or flash. Trash mode, header rows (whereSelectedTask()returns nil), soft-deleted rows, and a nilm.connall short-circuit toreturn nilso the key is inert in modes where reorder has no defined behaviour. Optimistic flow (internal/tui/actions.go+internal/tui/msghandler.go): on the first move of a gesture,m.preReorderTaskssnapshotsm.tasks; the active list is replaced withmergeActiveWithDeleted(newOrder, m.tasks)so the soft-deleted tail stays lossless for trash-mode round-trips;taskList.SetTasks(m.tasks)+ newTaskList.SelectTaskByNumber(num int32)(ininternal/tui/tasklist.go) keep the cursor glued to the moved row across the rebuild so the user can holdShift+↓and have the highlight chase the task;m.inFlightReorder = truearms the race guard;reorderTasksCmd(new ininternal/tui/commands.go) fires the gRPC call and returnsReorderCompletedMsg{Tasks, Focused}on success orReorderFailedMsg{Err, Focused}on failure (both defined ininternal/tui/messages.go). On completion the msg handler accepts the server's response as the new authoritativem.tasks, re-selects the focused row, and clearsinFlightReorder+preReorderTasks. On failure it restoresm.tasksfrompreReorderTasks, re-selects the focused row, setsm.err = "Reorder failed — reverted"(routed through the existing error-bar toast pipeline withclearErrorAfter(3 * time.Second)), and clears the in-flight state. Race fix for poll-driven refreshes (internal/tui/msghandler.go::handleMessageonTasksLoadedMsg): wheninFlightReorder == trueand the incoming active task-number set matchesm.tasks's active set (new helpersameActiveTaskSet), the refresh is dropped — accepting it would snap the moved row back to the server's pre-RPC slot before the RPC response lands. When the sets diverge (a concurrent create / delete happened during the round-trip), the optimistic order is structurally invalid and the server view wins. Help overlay (internal/tui/help.go) gainsShift+↑"Move selected task up" andShift+↓"Move selected task down" entries under the Task List section. Tests ininternal/tui/reorder_test.go(15 cases) cover: same-section up + down swaps yielding the expected new ordering, top + bottom boundary no-ops, cross-section no-op (Draft↔Ready boundary), the Draft → Ready → Done grouping invariant ofactiveTasksInDisplayOrder, the deleted-task filter,mergeActiveWithDeletedpreserving the deleted tail, header-row dispatch returning nil, offline (m.conn == nil) dispatch returning nil,ReorderFailedMsgrevertingm.tasks+ clearing flags + populatingm.err,ReorderCompletedMsgaccepting the server response + re-selecting the focused row, the stale-refresh race drop, the diverged-set acceptance leg, and Shift+↑ dispatch throughhandleTaskListKeynot falling through to the generic navigation handler.make buildandmake testgreen; all 32 TUI tests pass (17 pre-existing + 15 new). - GUI drag-to-reorder for active tasks (#0099). The Tasks tab now reuses the same
@dnd-kitpieces already proven out bygui/src/renderer/src/components/Sidebar.tsx—DndContext+SortableContext+useSortable+arrayMovefrom@dnd-kit/sortable— so there's no new dependency and no second drag-and-drop pattern to maintain. Each active status group ("In Development" / "Todo") ingui/src/renderer/src/views/ProjectView/TasksTab/TaskGroup.tsxnow owns its ownDndContextwithPointerSensor({ activationConstraint: { distance: 8 } })andclosestCentercollision; "Failed" and "Done" groups render the legacy non-sortableTaskItemso collapsed historical groups stay click-to-open with zero drag affordance. Rows ingui/src/renderer/src/views/ProjectView/TasksTab/TaskItem.tsxget a newsortable?: booleanprop — when true,useSortable({ id: String(task.taskNumber) })is bound to the row container (transform + transition + 0.5 opacity while dragging) and aGripVerticalicon at the left of the row is the only target wired up withattributes+listeners(withonClickstopPropagation +touch-noneso trackpad gestures don't get hijacked). The row body itself keeps its existingonClick={() => setEditOpen(true)}modal-open behaviour, andPointerSensor's 8 px activation distance means a stray pixel of pointer drift on a click no longer turns into a reorder.useSortableis called unconditionally withdisabled: !sortableto keep React hook order stable on the Done/Failed leg. Drag-end inTaskGroup.handleDragEndbuilds the flat reorder payload locally —[...arrayMove(groupTasks, oldIndex, newIndex).map(t=>t.taskNumber), ...allActiveTasks.filter(notInGroup).map(t=>t.taskNumber)]— so the newTaskService.ReorderTasksRPC (#0097) receives the whole project's active order with the dragged group's new sequence first. Cross-group drags are a structural impossibility (each group'sSortableContextis its own DndContext, no shared draggables), and the sameoldIndex === -1 || newIndex === -1early-return covers the vanished-row case where a task transitionsready → donemid-drag and disappears from the group'sitemsarray between dragstart and dragend.gui/src/renderer/src/views/ProjectView/TasksTab/TasksTab.tsxpassessortable,onReorder={reorderTasks(projectId, …)}, andallTasks={activeTasks}to both sortable groups; the failed/done groups still pass nothing extra so the legacy render path is byte-identical. Store side (gui/src/renderer/src/stores/tasks-store.ts): the existingreorderTasks(projectId, taskNumbers)action — which previously didawait client.reorderTasks(...)+fetchTasks(two round-trips, visible flicker between the optimistic order the user saw mid-drag and the eventual server response) — is rewritten to snapshotprevious = get().tasks[projectId], apply the optimistic order in-memory via abyNumbermap (named tasks first in the requested order, any not in the list keep their relative position at the tail), then callclient.reorderTasks(...)and set the response'stasksas the new authoritative state. On any RPC rejection the snapshot is restored and the error is re-thrown so the caller can show a toast —TaskGroup.handleDragEndcatches and callstoast(\Reorder failed: ${err}`, 'error')through the existinguseToast()pipeline. Tests:gui/src/renderer/src/views/ProjectView/TasksTab/TaskReorder.test.mjsmirrorsTaskModal.test.mjs's pure-helper convention —arrayMove,buildReorderPayload,optimisticReorder, andrevertOnErrorare mirrored inline sonode --testruns without a TS toolchain. Eleven cases cover: bottom-to-top within ready, single-slot swap, draft-only reorder, drop-on-self → null,over: null` (vanished mid-drag) → null, cross-group active id → null, done tasks excluded from the payload, optimistic happy + partial-list paths, snapshot revert, and the failure-path harness (rpc reject → revert + toast string). Existing 104 GUI tests still pass alongside the 11 new ones. TaskService.ReorderTasksserver handler + manager method (#0097). The proto declaredTaskService.ReorderTasks(ReorderTasksRequest) returns (TaskList)(proto/watchfire.proto:515) but no server handler existed, so any call hitUnimplementedTaskServiceServer.ReorderTasksand returnedcodes.Unimplemented— blocking v7's drag-to-reorder UI in #0098 (TUI) and #0099 (GUI). Newtask.Manager.ReorderTasks(projectPath string, taskNumbers []int) ([]*models.Task, error)mirrors the shape ofproject.Manager.ReorderProjects(internal/daemon/project/manager.go:328) on the task plane: loads the active set viaconfig.LoadActiveTasks, builds abyNumbermap, walkstaskNumbersvalidating each (unknown →task not found: <n>; duplicate →duplicate task in reorder request: <n>), appends any unmentioned active tasks in canonical Position-then-TaskNumber order so a partial-list request silently parks the leftovers at the end of the queue, then rewrites positions densely 1..N and persists each viaconfig.SaveTask. Re-lists throughListTasksso the response reflects the persisted state through #0096's canonical sort. NewtaskService.ReorderTaskshandler ininternal/daemon/server/task_service.goresolves the project path, converts[]int32→[]int, mapstask not found/duplicate task in reorder requesterrors tocodes.InvalidArgumentand any other error tocodes.Internal, then marshals the returned[]*models.Taskthrough the existingmodelToProtoTaskhelper. Concurrent reorders are unguarded — single-threaded gRPC dispatch and atomicSaveTaskwrites keep ordering stable; #0096'sPosition-then-TaskNumbersort produces a total order even on a briefly non-dense set, and the next reorder normalises. Tests ininternal/daemon/task/manager_test.gocover the happy path ([3,1,4,2] → 3=1, 1=2, 4=3, 2=4 with disk verification), the partial-list leftover branch ([3,1] of 4 → 3=1, 1=2, 2=3, 4=4), unknown task number rejection, duplicate-in-request rejection, soft-deleted task excluded from the active set (returns "task not found"), and idempotency (positions stay dense 1..N + order stable across two identical calls).
Fixed
- GUI Open-in-IDE — finds CLIs installed outside the GUI's stripped-down PATH. Launching the GUI from Finder / Dock on macOS inherits a minimal PATH (
/usr/bin:/bin:/usr/sbin:/sbin) —path_helperonly runs from login shells, andspawn(..., {shell: true})uses non-login/bin/sh -c, so the user's profile PATH is never sourced.codeat/usr/local/bin/code(and Homebrew shims at/opt/homebrew/bin) failed to resolve and every IDE pick errored out. NewspawnEnv()helper ingui/src/main/ipc.tsprepends the well-known macOS install locations (/usr/local/bin,/usr/local/sbin,/opt/homebrew/bin,/opt/homebrew/sbin,~/.local/bin,~/bin) to PATH for the spawned process; Linux gets~/.local/bin+~/bin. Wired through the CLI-mode spawn inopenInIDEvia the newenv:option. Fixesvscode,cursor,windsurf,zed,subl,webstorm,idea, andfleet;xcode(uses macOSopen -a) andfinder(usesshell.openPath) were never affected. Bug only surfaced when the GUI was launched from Finder/Dock —npm run devin a terminal inherited the shell PATH and masked the issue. - Task work order — oldest-first by (position ASC, task_number ASC); new tasks default to bottom (#0096).
internal/daemon/task/manager.go::ListTaskswas sorting strictly descending bytask_number, contradicting theARCHITECTURE.md"Task Work Order" rule. Every consumer readstasks[0]— thestart-allchain (internal/daemon/server/server.go:154) and both wildfire pickers (:178,:202) — sowatchfire start-allandwatchfire wildfirewalked a 4-task queue backwards (4 → 3 → 2 → 1) instead of forward (1 → 2 → 3 → 4). ThePositionfield was already onmodels.Taskand surfaced through proto + converters but never read by the sort, so the dead-data path silently stripped the manual-override knob the spec relied on. Replaced the sort with the spec'd compound ascending order:position ASCprimary,task_number ASCtiebreaker. Every TUI / GUI / gRPC consumer plus the next-task pickers inherit the fix without further changes.internal/daemon/task/manager.go::CreateTaskalso changed: the defaulttask.Position = taskNumberwas fine for greenfield projects but broke after any manual reorder (a new task withPosition = taskNumbercould jump ahead of tasks the user just dragged down). New default ismax(active.position)+1(or1if zero active tasks) — appends to the bottom of the work queue. An explicitopts.Positionstill wins.models.NewTaskno longer setsPosition; the manager owns ordering. Tests ininternal/daemon/task/manager_test.gorewritten:TestListTasksAscendingByPositionThenTaskNumberexercises both legs of the compound sort with(pos=2,num=1),(pos=1,num=2),(pos=1,num=3)→ expected(1,2),(1,3),(2,1); newTestCreateTaskDefaultsToBottomOfQueuecovers empty-project, dense-1..3, and sparse-max-10 scenarios plus the explicit-override leg.ARCHITECTURE.mdTask Work Order section gains the new-task-bottom rule; "Task drag-to-reorder" is removed from the TUI Excluded list (v7 #0097 + #0098 + #0099 ship it). - GUI Chat terminal — viewport no longer snaps to byte 0 on scroll (#0100). Two stacked causes both fixed. Client side (
gui/src/renderer/src/hooks/useAgentTerminal.ts): the subscribe effect previously ranterm.clear()+ abort + re-subscribe on every dep change, andChatTab.tsx's 2 s status poll routinely flippedactivetrue → false → truewhenevergetAgentStatushit a transient error (agent-store.ts:56-62sets{isRunning: false}on catch), spuriously replaying the full daemon raw buffer from byte 0 mid-scroll. The effect is now idempotent — whenactive=trueand the previousAbortControlleris still unaborted, the effect bails out (no clear, no resubscribe, no daemon round-trip). The unsubscribe path is debounced 3 s so a single poll flicker can't tear down a live subscription. Manual abort + bytes-cursor-preserving resubscribe still runs onreconnectKeybump (the onEnd path's deliberate reconnect after wildfire phase transitions) and onprojectIdchange (cursor reset to 0).term.clear()is no longer called anywhere. Server side (internal/daemon/agent/process.go+internal/daemon/server/agent_service.go): new catch-up cursor —ProcesstracksrawTotalBytes(monotonic count of broadcast bytes), and the newSubscribeRawFrom(id, bytesReceived)slices the late-join snapshot so only bytes past the client's offset are sent. Wire:proto/watchfire.protoSubscribeRawOutputRequestgains anint64 bytes_receivedfield;agent_service.SubscribeRawOutputthreads it intoSubscribeRawFrom. Negative / past-end cursors are clamped; cursors beforebufStart(=rawTotalBytes - len(rawBuf), the floor of the 1 MiB rolling buffer) return the full buffer — the gap is genuinely lost data and the client gets whatever the daemon can still produce. Picked daemon-side cursor over client-side dedup because dedup against arbitrary terminal-control sequences is lossy in practice; the proto change is the only server-side surface touched. Also: xtermscrollbackraised from the default 1 000 to 10 000 lines (multi-minute agent sessions blew past 1 000 instantly);ResizeObservernow records the last(rows, cols)it sent and bails when the fitted dims are unchanged, so scrollbar-appearance nudges no longer spam the daemon with no-op resize RPCs. Tests:internal/daemon/agent/process_test.go::TestSubscribeRawFrom_CursorSlice(eight slice-math cases incl. negative / past-end / aged-out clamps) +TestSubscribeRawFrom_LiveBroadcast(post-subscribe live channel +rawTotalBytesmonotonicity);gui/src/renderer/src/hooks/useAgentTerminal.test.mjs(10 cases — idempotent re-runs produce one subscribe, flicker survives a 500 msfalsewindow, debounce fires at 3 s, reconnect preserves cursor, project switch resets cursor, structural locks ensuringterm.clear()stays gone andscrollback: 10000stays set).