diff --git a/src/browser/components/AddSectionButton/AddSectionButton.tsx b/src/browser/components/AddSectionButton/AddSectionButton.tsx deleted file mode 100644 index beff820652..0000000000 --- a/src/browser/components/AddSectionButton/AddSectionButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useState, useRef, useEffect } from "react"; -import { Plus } from "lucide-react"; -// import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; - -interface AddSectionButtonProps { - onCreateSection: (name: string) => void; -} - -export const AddSectionButton: React.FC = ({ onCreateSection }) => { - const [isCreating, setIsCreating] = useState(false); - const [name, setName] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - if (isCreating && inputRef.current) { - inputRef.current.focus(); - } - }, [isCreating]); - - const handleSubmit = () => { - const trimmed = name.trim(); - if (trimmed) { - onCreateSection(trimmed); - } - setName(""); - setIsCreating(false); - }; - - if (isCreating) { - return ( -
- setName(e.target.value)} - onBlur={handleSubmit} - onKeyDown={(e) => { - if (e.key === "Enter") handleSubmit(); - if (e.key === "Escape") { - setName(""); - setIsCreating(false); - } - }} - placeholder="Section name..." - data-testid="add-section-input" - className="bg-background/50 text-foreground ml-6 min-w-0 flex-1 rounded border border-white/20 px-1.5 py-0.5 text-[11px] outline-none select-text" - /> -
- ); - } - - return ( - - ); -}; diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 89f3f9f607..7f23ef9745 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -35,7 +35,6 @@ import * as ProjectDeleteConfirmationModalModule from "../ProjectDeleteConfirmat import * as WorkspaceStatusIndicatorModule from "../WorkspaceStatusIndicator/WorkspaceStatusIndicator"; import * as PopoverErrorModule from "../PopoverError/PopoverError"; import * as SectionHeaderModule from "../SectionHeader/SectionHeader"; -import * as AddSectionButtonModule from "../AddSectionButton/AddSectionButton"; import * as WorkspaceSectionDropZoneModule from "../WorkspaceSectionDropZone/WorkspaceSectionDropZone"; import * as WorkspaceDragLayerModule from "../WorkspaceDragLayer/WorkspaceDragLayer"; import * as SectionDragLayerModule from "../SectionDragLayer/SectionDragLayer"; @@ -398,9 +397,6 @@ function installProjectSidebarTestDoubles() { spyOn(SectionHeaderModule, "SectionHeader").mockImplementation( (() => null) as unknown as typeof SectionHeaderModule.SectionHeader ); - spyOn(AddSectionButtonModule, "AddSectionButton").mockImplementation( - (() => null) as unknown as typeof AddSectionButtonModule.AddSectionButton - ); spyOn(WorkspaceSectionDropZoneModule, "WorkspaceSectionDropZone").mockImplementation( TestWrapper as unknown as typeof WorkspaceSectionDropZoneModule.WorkspaceSectionDropZone ); diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index f81372fe25..d01364b434 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -21,6 +21,10 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; import { Popover, PopoverTrigger, PopoverContent } from "../Popover/Popover"; import { Checkbox } from "../Checkbox/Checkbox"; import { formatKeybind, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; +import { + buildArchiveConfirmDescription, + buildArchiveConfirmWarning, +} from "@/browser/utils/archiveConfirmation"; import { getDevcontainerStatusChip } from "@/browser/utils/runtimeUi"; import { useGitStatus } from "@/browser/stores/GitStatusStore"; import { useRuntimeStatus, useRuntimeStatusStoreRaw } from "@/browser/stores/RuntimeStatusStore"; @@ -68,11 +72,6 @@ interface WorkspaceMenuBarProps { onOpenTerminal?: (options?: TerminalSessionCreateOptions) => void; } -import { - buildArchiveConfirmDescription, - buildArchiveConfirmWarning, -} from "@/browser/utils/archiveConfirmation"; - export const WorkspaceMenuBar: React.FC = ({ workspaceId, projectName, diff --git a/src/browser/features/LandingPage/LandingPage.stories.tsx b/src/browser/features/LandingPage/LandingPage.stories.tsx index 14960cc9aa..443d1812a8 100644 --- a/src/browser/features/LandingPage/LandingPage.stories.tsx +++ b/src/browser/features/LandingPage/LandingPage.stories.tsx @@ -17,7 +17,6 @@ import { } from "@/browser/stories/meta.js"; import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; import { createWorkspace, groupWorkspacesByProject } from "@/browser/stories/mocks/workspaces"; -import { LEFT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; // Integration: stories render full app to test landing page layout with sidebar, analytics, and workspace cards. export default { @@ -168,7 +167,7 @@ export const SidebarCollapsed: AppStory = { render: () => ( { - localStorage.setItem(LEFT_SIDEBAR_COLLAPSED_KEY, JSON.stringify(true)); + collapseLeftSidebar(); expandProjects([PROJECT_PATH]); const client = createMockORPCClient({ projects: groupWorkspacesByProject(WORKSPACES), diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 6a0a69d319..1cab3d0779 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -233,11 +233,6 @@ --color-input-border: hsl(207 51% 59%); --color-input-border-focus: hsl(193 91% 64%); - /* Scrollbar */ - --color-scrollbar-track: hsl(0 0% 18%); - --color-scrollbar-thumb: hsl(0 0% 32%); - --color-scrollbar-thumb-hover: hsl(0 0% 42%); - /* Additional Semantic Colors */ --color-muted: hsl(0 0% 53%); /* #888 - muted text */ --color-muted-light: hsl(0 0% 50%); /* #808080 - muted light */ @@ -515,10 +510,6 @@ --color-input-border: hsl(207 75% 52%); --color-input-border-focus: hsl(193 85% 56%); - --color-scrollbar-track: hsl(210 38% 95%); - --color-scrollbar-thumb: hsl(210 18% 78%); - --color-scrollbar-thumb-hover: hsl(210 18% 70%); - --color-muted: hsl(210 14% 52%); --color-muted-light: hsl(210 20% 60%); --color-muted-dark: hsl(210 12% 42%); @@ -764,10 +755,6 @@ --color-input-border: #205ea6; --color-input-border-focus: color-mix(in srgb, var(--color-input-border), white 30%); - --color-scrollbar-track: #f2f0e5; - --color-scrollbar-thumb: #dad8ce; - --color-scrollbar-thumb-hover: #cecdc3; - --color-muted: #6f6e69; --color-muted-light: #6f6e69; --color-muted-dark: #6f6e69; @@ -997,10 +984,6 @@ --color-input-border: #4385be; --color-input-border-focus: color-mix(in srgb, var(--color-input-border), white 22%); - --color-scrollbar-track: #1c1b1a; - --color-scrollbar-thumb: #343331; - --color-scrollbar-thumb-hover: #403e3c; - --color-muted: #878580; --color-muted-light: #cecdc3; --color-muted-dark: #575653; diff --git a/src/common/utils/messages/compactionBoundary.ts b/src/common/utils/messages/compactionBoundary.ts index bab5a9b50e..8c218932d6 100644 --- a/src/common/utils/messages/compactionBoundary.ts +++ b/src/common/utils/messages/compactionBoundary.ts @@ -1,13 +1,8 @@ import assert from "@/common/utils/assert"; +import { isPositiveInteger } from "@/common/utils/numbers"; import type { MuxMessage } from "@/common/types/message"; -function isPositiveInteger(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 - ); -} - export function isDurableCompactedMarker( value: unknown ): value is true | "user" | "idle" | "heartbeat" { diff --git a/src/common/utils/numbers.ts b/src/common/utils/numbers.ts new file mode 100644 index 0000000000..1749b5efe4 --- /dev/null +++ b/src/common/utils/numbers.ts @@ -0,0 +1,16 @@ +/** + * Number type guards used across compaction, history, and workspace services + * to validate persisted metadata fields. + */ + +export function isPositiveInteger(value: unknown): value is number { + return ( + typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 + ); +} + +export function isNonNegativeInteger(value: unknown): value is number { + return ( + typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 + ); +} diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 1b883dcadb..0bd6027ede 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3985,7 +3985,7 @@ export class AgentSession { if (this.ackPendingPostCompactionStateOnStreamEnd) { this.ackPendingPostCompactionStateOnStreamEnd = false; try { - await this.compactionHandler.ackPendingDiffsConsumed(); + await this.compactionHandler.ackPendingStateConsumed(); } catch (error) { log.warn("Failed to ack pending post-compaction state", { workspaceId: this.workspaceId, diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index c82dd4861b..de7900a063 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -1,6 +1,7 @@ import type { EventEmitter } from "events"; import * as fsPromises from "fs/promises"; import assert from "@/common/utils/assert"; +import { isNonNegativeInteger, isPositiveInteger } from "@/common/utils/numbers"; import * as path from "path"; import type { HistoryService } from "./historyService"; @@ -247,18 +248,6 @@ function isCompactedSummaryMessage(message: MuxMessage): boolean { return isDurableCompactedMarker(message.metadata?.compacted); } -function isPositiveInteger(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 - ); -} - -function isNonNegativeInteger(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 - ); -} - function getNextCompactionEpoch(messages: MuxMessage[]): number { let epochCursor = 0; @@ -462,10 +451,6 @@ export class CompactionHandler { await this.deletePersistedPendingStateBestEffort(); } - async ackPendingDiffsConsumed(): Promise { - await this.ackPendingStateConsumed(); - } - /** * Drop pending post-compaction state (e.g., because it caused context_exceeded). */ @@ -490,10 +475,6 @@ export class CompactionHandler { this.cachedLoadedSkills = []; } - async discardPendingDiffs(reason: string): Promise { - await this.discardPendingState(reason); - } - private async deletePersistedPendingStateBestEffort(): Promise { try { await fsPromises.unlink(this.postCompactionStatePath); diff --git a/src/node/services/heartbeatService.ts b/src/node/services/heartbeatService.ts index 551260aaf7..52dac9e2e3 100644 --- a/src/node/services/heartbeatService.ts +++ b/src/node/services/heartbeatService.ts @@ -1,8 +1,7 @@ import assert from "@/common/utils/assert"; import type { MuxMessage } from "@/common/types/message"; import type { ProjectsConfig, Workspace } from "@/common/types/project"; -import type { WorkspaceActivitySnapshot } from "@/common/types/workspace"; -import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { WorkspaceActivitySnapshot, WorkspaceMetadata } from "@/common/types/workspace"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { HEARTBEAT_DEFAULT_INTERVAL_MS, diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 00fa05db99..c50db80015 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -22,18 +22,7 @@ import { isDurableCompactionBoundaryMarker, } from "@/common/utils/messages/compactionBoundary"; import { getErrorMessage } from "@/common/utils/errors"; - -function isPositiveInteger(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 - ); -} - -function isNonNegativeInteger(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 - ); -} +import { isNonNegativeInteger, isPositiveInteger } from "@/common/utils/numbers"; function hasDurableCompactionBoundary(metadata: MuxMetadata | undefined): boolean { if (metadata?.compactionBoundary !== true) { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 1418481a53..24dd443253 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -54,6 +54,7 @@ import { shellQuote } from "@/node/runtime/backgroundCommands"; import { extractEditedFilePaths } from "@/common/utils/messages/extractEditedFiles"; import { buildCompactionMessageText } from "@/common/utils/compaction/compactionPrompt"; import { isDurableCompactedMarker } from "@/common/utils/messages/compactionBoundary"; +import { isNonNegativeInteger, isPositiveInteger } from "@/common/utils/numbers"; import { deriveTodoStatus } from "@/common/utils/todoList"; import { fileExists } from "@/node/utils/runtime/fileExists"; import { orchestrateFork } from "@/node/services/utils/forkOrchestrator"; @@ -211,22 +212,8 @@ type WorktreeArchiveSnapshotLifecycleService = Pick< | "restoreSnapshotAfterUnarchive" | "getUnsupportedUntrackedPaths" >; -function normalizeHeartbeatMessageInput(message: string | undefined): string | undefined { - if (message == null) { - return undefined; - } - - assert(typeof message === "string", "Heartbeat message must be a string when provided"); - const trimmedMessage = message.trim(); - if (trimmedMessage.length === 0) { - return undefined; - } - - return trimmedMessage; -} - -// Persisted workspace config can contain non-string or whitespace-only values; normalize the -// message on read so an invalid override never bricks heartbeat execution. +// Trim and normalize a heartbeat message for storage. Accepts `unknown` so it safely handles +// both user input (string | undefined) and persisted config values that may have been corrupted. function sanitizeHeartbeatMessage(message: unknown): string | undefined { if (typeof message !== "string") { return undefined; @@ -563,18 +550,6 @@ async function resetForkedSessionUsage( ); } -function isPositiveInteger(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 - ); -} - -function isNonNegativeInteger(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 - ); -} - function getOldestSequencedMessage( messages: readonly MuxMessage[] ): { message: MuxMessage; historySequence: number } | null { @@ -3290,18 +3265,12 @@ export class WorkspaceService extends EventEmitter { const message = sanitizeHeartbeatMessage(workspaceEntry.heartbeat.message); const contextMode = sanitizeHeartbeatContextMode(workspaceEntry.heartbeat.contextMode); - return message == null - ? { - enabled: workspaceEntry.heartbeat.enabled, - intervalMs: workspaceEntry.heartbeat.intervalMs, - contextMode, - } - : { - enabled: workspaceEntry.heartbeat.enabled, - intervalMs: workspaceEntry.heartbeat.intervalMs, - message, - contextMode, - }; + return { + enabled: workspaceEntry.heartbeat.enabled, + intervalMs: workspaceEntry.heartbeat.intervalMs, + contextMode, + ...(message != null ? { message } : {}), + }; } async setHeartbeatSettings( @@ -3354,26 +3323,18 @@ export class WorkspaceService extends EventEmitter { } const nextMessage = hasMessageUpdate - ? normalizeHeartbeatMessageInput(settings.message) + ? sanitizeHeartbeatMessage(settings.message) : sanitizeHeartbeatMessage(workspaceEntry.heartbeat?.message); const nextContextMode = hasContextModeUpdate ? sanitizeHeartbeatContextMode(settings.contextMode) : sanitizeHeartbeatContextMode(workspaceEntry.heartbeat?.contextMode); - const nextSettings: WorkspaceHeartbeatSettings = - nextMessage == null - ? { - enabled: settings.enabled, - // Keep the interval on disk even when disabled so re-enabling restores the user's choice. - intervalMs: settings.intervalMs, - contextMode: nextContextMode, - } - : { - enabled: settings.enabled, - // Keep the interval on disk even when disabled so re-enabling restores the user's choice. - intervalMs: settings.intervalMs, - message: nextMessage, - contextMode: nextContextMode, - }; + // Keep the interval on disk even when disabled so re-enabling restores the user's choice. + const nextSettings: WorkspaceHeartbeatSettings = { + enabled: settings.enabled, + intervalMs: settings.intervalMs, + contextMode: nextContextMode, + ...(nextMessage != null ? { message: nextMessage } : {}), + }; const changed = workspaceEntry.heartbeat?.enabled !== nextSettings.enabled || diff --git a/src/node/services/worktreeArchiveSnapshotService.ts b/src/node/services/worktreeArchiveSnapshotService.ts index 2000a53b06..ccfcdbaa7d 100644 --- a/src/node/services/worktreeArchiveSnapshotService.ts +++ b/src/node/services/worktreeArchiveSnapshotService.ts @@ -291,38 +291,16 @@ export class WorktreeArchiveSnapshotService { const acknowledgedSet = new Set(args.acknowledgedUntrackedPaths); const newPaths = currentUntracked.filter((p) => !acknowledgedSet.has(p)); if (newPaths.length > 0) { - const latestUntrackedResult = await this.getUnsupportedUntrackedPaths({ + return this.buildUntrackedConfirmationErr({ workspaceId: args.workspaceId, workspaceMetadata: args.workspaceMetadata, }); - if (!latestUntrackedResult.success) { - return Err(latestUntrackedResult.error); - } - assert( - latestUntrackedResult.data.length > 0, - "captureSnapshotForArchive: expected current untracked paths when confirmation is required" - ); - return Err({ - kind: "confirm-lossy-untracked-files", - paths: latestUntrackedResult.data, - }); } } else if (currentUntracked.length > 0) { - const latestUntrackedResult = await this.getUnsupportedUntrackedPaths({ + return this.buildUntrackedConfirmationErr({ workspaceId: args.workspaceId, workspaceMetadata: args.workspaceMetadata, }); - if (!latestUntrackedResult.success) { - return Err(latestUntrackedResult.error); - } - assert( - latestUntrackedResult.data.length > 0, - "captureSnapshotForArchive: expected current untracked paths when confirmation is required" - ); - return Err({ - kind: "confirm-lossy-untracked-files", - paths: latestUntrackedResult.data, - }); } await this.ensureNoDirtySubmodules(projectRepo.repoCwd); @@ -680,6 +658,32 @@ export class WorktreeArchiveSnapshotService { return detectDefaultTrunkBranch(args.projectPath); } + /** + * Fetch the current untracked-file set for a workspace and return an + * `Err` asking the user to re-confirm. Used by `captureSnapshotForArchive` + * when untracked files are detected that the user hasn't acknowledged. + */ + private async buildUntrackedConfirmationErr(args: { + workspaceId: string; + workspaceMetadata: WorkspaceMetadata; + }): Promise> { + const latestUntrackedResult = await this.getUnsupportedUntrackedPaths({ + workspaceId: args.workspaceId, + workspaceMetadata: args.workspaceMetadata, + }); + if (!latestUntrackedResult.success) { + return Err(latestUntrackedResult.error); + } + assert( + latestUntrackedResult.data.length > 0, + "captureSnapshotForArchive: expected current untracked paths when confirmation is required" + ); + return Err({ + kind: "confirm-lossy-untracked-files", + paths: latestUntrackedResult.data, + }); + } + /** * List untracked files/directories in a repo that archive snapshots cannot preserve. * Returns a sorted, normalized array of relative paths.