Skip to content

v7.0.0 — Forge

Choose a tag to compare

@github-actions github-actions released this 12 May 14:46
· 7 commits to main since this release

[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+↑ and Shift+↓ on a focused active row swap the selected task with its in-bounds same-status neighbour and fire TaskService.ReorderTasks (the v7 RPC landed in #0097) with the project's full new active task-number ordering. The chord lands in taskListKeys.MoveUp / MoveDown (internal/tui/keys.go) bound exclusively to shift+up / shift+downK / J letter chords were dropped after auditing keys.go and finding lowercase j / k already 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 repeating j. The terminal handler's pre-existing shift+up / shift+down line-scroll case (internal/tui/keyhandler.go:519) sits inside handleTerminalKey which only runs when focusedPanel == 1, so the left-panel dispatch in handleTaskListKey cannot collide. New Model.moveSelectedTask(direction int) in internal/tui/actions.go derives a Draft → Ready → Done flat list of active tasks via activeTasksInDisplayOrder (filters GetDeletedAt() != nil, preserves the canonical position-then-task_number order already produced by the server's #0096 sort), locates the focused row, calls reorderActiveTasks to 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 (where SelectedTask() returns nil), soft-deleted rows, and a nil m.conn all short-circuit to return nil so 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.preReorderTasks snapshots m.tasks; the active list is replaced with mergeActiveWithDeleted(newOrder, m.tasks) so the soft-deleted tail stays lossless for trash-mode round-trips; taskList.SetTasks(m.tasks) + new TaskList.SelectTaskByNumber(num int32) (in internal/tui/tasklist.go) keep the cursor glued to the moved row across the rebuild so the user can hold Shift+↓ and have the highlight chase the task; m.inFlightReorder = true arms the race guard; reorderTasksCmd (new in internal/tui/commands.go) fires the gRPC call and returns ReorderCompletedMsg{Tasks, Focused} on success or ReorderFailedMsg{Err, Focused} on failure (both defined in internal/tui/messages.go). On completion the msg handler accepts the server's response as the new authoritative m.tasks, re-selects the focused row, and clears inFlightReorder + preReorderTasks. On failure it restores m.tasks from preReorderTasks, re-selects the focused row, sets m.err = "Reorder failed — reverted" (routed through the existing error-bar toast pipeline with clearErrorAfter(3 * time.Second)), and clears the in-flight state. Race fix for poll-driven refreshes (internal/tui/msghandler.go::handleMessage on TasksLoadedMsg): when inFlightReorder == true and the incoming active task-number set matches m.tasks's active set (new helper sameActiveTaskSet), 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) gains Shift+↑ "Move selected task up" and Shift+↓ "Move selected task down" entries under the Task List section. Tests in internal/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 of activeTasksInDisplayOrder, the deleted-task filter, mergeActiveWithDeleted preserving the deleted tail, header-row dispatch returning nil, offline (m.conn == nil) dispatch returning nil, ReorderFailedMsg reverting m.tasks + clearing flags + populating m.err, ReorderCompletedMsg accepting the server response + re-selecting the focused row, the stale-refresh race drop, the diverged-set acceptance leg, and Shift+↑ dispatch through handleTaskListKey not falling through to the generic navigation handler. make build and make test green; 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-kit pieces already proven out by gui/src/renderer/src/components/Sidebar.tsxDndContext + SortableContext + useSortable + arrayMove from @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") in gui/src/renderer/src/views/ProjectView/TasksTab/TaskGroup.tsx now owns its own DndContext with PointerSensor({ activationConstraint: { distance: 8 } }) and closestCenter collision; "Failed" and "Done" groups render the legacy non-sortable TaskItem so collapsed historical groups stay click-to-open with zero drag affordance. Rows in gui/src/renderer/src/views/ProjectView/TasksTab/TaskItem.tsx get a new sortable?: boolean prop — when true, useSortable({ id: String(task.taskNumber) }) is bound to the row container (transform + transition + 0.5 opacity while dragging) and a GripVertical icon at the left of the row is the only target wired up with attributes+listeners (with onClick stopPropagation + touch-none so trackpad gestures don't get hijacked). The row body itself keeps its existing onClick={() => setEditOpen(true)} modal-open behaviour, and PointerSensor's 8 px activation distance means a stray pixel of pointer drift on a click no longer turns into a reorder. useSortable is called unconditionally with disabled: !sortable to keep React hook order stable on the Done/Failed leg. Drag-end in TaskGroup.handleDragEnd builds the flat reorder payload locally — [...arrayMove(groupTasks, oldIndex, newIndex).map(t=>t.taskNumber), ...allActiveTasks.filter(notInGroup).map(t=>t.taskNumber)] — so the new TaskService.ReorderTasks RPC (#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's SortableContext is its own DndContext, no shared draggables), and the same oldIndex === -1 || newIndex === -1 early-return covers the vanished-row case where a task transitions ready → done mid-drag and disappears from the group's items array between dragstart and dragend. gui/src/renderer/src/views/ProjectView/TasksTab/TasksTab.tsx passes sortable, onReorder={reorderTasks(projectId, …)}, and allTasks={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 existing reorderTasks(projectId, taskNumbers) action — which previously did await 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 snapshot previous = get().tasks[projectId], apply the optimistic order in-memory via a byNumber map (named tasks first in the requested order, any not in the list keep their relative position at the tail), then call client.reorderTasks(...) and set the response's tasks as 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.handleDragEnd catches and calls toast(\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, and revertOnErrorare 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.ReorderTasks server handler + manager method (#0097). The proto declared TaskService.ReorderTasks(ReorderTasksRequest) returns (TaskList) (proto/watchfire.proto:515) but no server handler existed, so any call hit UnimplementedTaskServiceServer.ReorderTasks and returned codes.Unimplemented — blocking v7's drag-to-reorder UI in #0098 (TUI) and #0099 (GUI). New task.Manager.ReorderTasks(projectPath string, taskNumbers []int) ([]*models.Task, error) mirrors the shape of project.Manager.ReorderProjects (internal/daemon/project/manager.go:328) on the task plane: loads the active set via config.LoadActiveTasks, builds a byNumber map, walks taskNumbers validating 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 via config.SaveTask. Re-lists through ListTasks so the response reflects the persisted state through #0096's canonical sort. New taskService.ReorderTasks handler in internal/daemon/server/task_service.go resolves the project path, converts []int32[]int, maps task not found / duplicate task in reorder request errors to codes.InvalidArgument and any other error to codes.Internal, then marshals the returned []*models.Task through the existing modelToProtoTask helper. Concurrent reorders are unguarded — single-threaded gRPC dispatch and atomic SaveTask writes keep ordering stable; #0096's Position-then-TaskNumber sort produces a total order even on a briefly non-dense set, and the next reorder normalises. Tests in internal/daemon/task/manager_test.go cover 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_helper only runs from login shells, and spawn(..., {shell: true}) uses non-login /bin/sh -c, so the user's profile PATH is never sourced. code at /usr/local/bin/code (and Homebrew shims at /opt/homebrew/bin) failed to resolve and every IDE pick errored out. New spawnEnv() helper in gui/src/main/ipc.ts prepends 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 in openInIDE via the new env: option. Fixes vscode, cursor, windsurf, zed, subl, webstorm, idea, and fleet; xcode (uses macOS open -a) and finder (uses shell.openPath) were never affected. Bug only surfaced when the GUI was launched from Finder/Dock — npm run dev in 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::ListTasks was sorting strictly descending by task_number, contradicting the ARCHITECTURE.md "Task Work Order" rule. Every consumer reads tasks[0] — the start-all chain (internal/daemon/server/server.go:154) and both wildfire pickers (:178, :202) — so watchfire start-all and watchfire wildfire walked a 4-task queue backwards (4 → 3 → 2 → 1) instead of forward (1 → 2 → 3 → 4). The Position field was already on models.Task and 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 ASC primary, task_number ASC tiebreaker. Every TUI / GUI / gRPC consumer plus the next-task pickers inherit the fix without further changes. internal/daemon/task/manager.go::CreateTask also changed: the default task.Position = taskNumber was fine for greenfield projects but broke after any manual reorder (a new task with Position = taskNumber could jump ahead of tasks the user just dragged down). New default is max(active.position)+1 (or 1 if zero active tasks) — appends to the bottom of the work queue. An explicit opts.Position still wins. models.NewTask no longer sets Position; the manager owns ordering. Tests in internal/daemon/task/manager_test.go rewritten: TestListTasksAscendingByPositionThenTaskNumber exercises 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); new TestCreateTaskDefaultsToBottomOfQueue covers empty-project, dense-1..3, and sparse-max-10 scenarios plus the explicit-override leg. ARCHITECTURE.md Task 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 ran term.clear() + abort + re-subscribe on every dep change, and ChatTab.tsx's 2 s status poll routinely flipped active true → false → true whenever getAgentStatus hit a transient error (agent-store.ts:56-62 sets {isRunning: false} on catch), spuriously replaying the full daemon raw buffer from byte 0 mid-scroll. The effect is now idempotent — when active=true and the previous AbortController is 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 on reconnectKey bump (the onEnd path's deliberate reconnect after wildfire phase transitions) and on projectId change (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 — Process tracks rawTotalBytes (monotonic count of broadcast bytes), and the new SubscribeRawFrom(id, bytesReceived) slices the late-join snapshot so only bytes past the client's offset are sent. Wire: proto/watchfire.proto SubscribeRawOutputRequest gains an int64 bytes_received field; agent_service.SubscribeRawOutput threads it into SubscribeRawFrom. Negative / past-end cursors are clamped; cursors before bufStart (= 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: xterm scrollback raised from the default 1 000 to 10 000 lines (multi-minute agent sessions blew past 1 000 instantly); ResizeObserver now 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 + rawTotalBytes monotonicity); gui/src/renderer/src/hooks/useAgentTerminal.test.mjs (10 cases — idempotent re-runs produce one subscribe, flicker survives a 500 ms false window, debounce fires at 3 s, reconnect preserves cursor, project switch resets cursor, structural locks ensuring term.clear() stays gone and scrollback: 10000 stays set).