@saadnvd1/agent-os 0.3.680 → 0.3.691

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +2 -2
  2. package/app/api/dev-servers/detect/route.ts +1 -2
  3. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +2 -4
  4. package/app/page.tsx +1 -2
  5. package/components/Artifacts/ArtifactsPanel.tsx +1 -1
  6. package/components/FileExplorer/FileTabs.tsx +1 -1
  7. package/components/Pane/DesktopTabBar.tsx +2 -2
  8. package/components/QuickSwitcher.tsx +24 -21
  9. package/components/SessionList/SessionRowMenu.tsx +0 -1
  10. package/components/Settings/ExperimentsSection.test.ts +7 -1
  11. package/components/Settings/NoApprovalsSection.test.ts +7 -1
  12. package/components/Terminal/TerminalToolbar.tsx +1 -1
  13. package/components/Terminal/VirtualKeyboard.tsx +1 -1
  14. package/components/WorkerCard.tsx +1 -7
  15. package/components/Workspaces/WorkspaceNameDialog.tsx +5 -3
  16. package/components/a/AButton.tsx +1 -6
  17. package/components/a/AIconButton.tsx +1 -5
  18. package/contexts/PaneContext.tsx +1 -6
  19. package/data/lumifyhub/index.ts +2 -4
  20. package/hooks/useDirectoryBrowser.ts +5 -4
  21. package/hooks/useDrawerAnimation.ts +11 -14
  22. package/hooks/useFileDrop.ts +2 -6
  23. package/hooks/useKeybarVisibility.ts +10 -29
  24. package/hooks/useSidebarPinned.ts +7 -23
  25. package/hooks/useSpeechRecognition.ts +19 -5
  26. package/hooks/useStoredFlag.ts +47 -0
  27. package/hooks/useViewport.ts +11 -17
  28. package/lib/approvals/no-approvals.ts +1 -7
  29. package/lib/artifacts/store.ts +1 -2
  30. package/lib/away/report.ts +1 -2
  31. package/lib/away/run.ts +1 -2
  32. package/lib/away/state.ts +1 -2
  33. package/lib/bookmarks/store.ts +1 -2
  34. package/lib/bus/delivery.ts +1 -2
  35. package/lib/bus/index.ts +1 -2
  36. package/lib/chat/compact-context.ts +1 -2
  37. package/lib/chat/stop.ts +1 -2
  38. package/lib/chat/worker/host.ts +1 -1
  39. package/lib/code-health/reports.ts +39 -3
  40. package/lib/code-health/source.ts +1 -1
  41. package/lib/db/lumifyhub.ts +2 -4
  42. package/lib/db/schedules-move.ts +1 -2
  43. package/lib/db/session-parts/tasks.ts +1 -2
  44. package/lib/db/stacks.ts +3 -9
  45. package/lib/db/types.ts +1 -7
  46. package/lib/dev-instance/spawnServer.ts +10 -6
  47. package/lib/doctor/checks.ts +8 -10
  48. package/lib/done/archive.ts +1 -2
  49. package/lib/env-setup.ts +2 -0
  50. package/lib/experiments/meter.ts +1 -7
  51. package/lib/files.ts +1 -0
  52. package/lib/health/env/classes.ts +0 -2
  53. package/lib/health/env/recordHit.ts +1 -2
  54. package/lib/health/spin.ts +1 -4
  55. package/lib/health/store.ts +1 -2
  56. package/lib/hosts/index.ts +1 -2
  57. package/lib/hosts/peer-sync.ts +2 -4
  58. package/lib/hosts/remote-api.ts +1 -2
  59. package/lib/lumifyhub/types.ts +2 -8
  60. package/lib/notify/index.ts +1 -2
  61. package/lib/notify/push/index.ts +2 -7
  62. package/lib/orchestrator/brief.ts +1 -1
  63. package/lib/orchestrator/external-pr.ts +1 -2
  64. package/lib/orchestrator/gates.ts +1 -6
  65. package/lib/orchestrator/needs-you-items.ts +16 -20
  66. package/lib/orchestrator/pr-wake.ts +1 -2
  67. package/lib/orchestrator/rebase.ts +1 -2
  68. package/lib/orchestrator/review.ts +1 -2
  69. package/lib/orchestrator/usage.ts +1 -2
  70. package/lib/plans/claim.ts +1 -2
  71. package/lib/plans/features.ts +4 -11
  72. package/lib/plans/release.ts +2 -4
  73. package/lib/ports.ts +1 -2
  74. package/lib/program-status/records.ts +1 -1
  75. package/lib/project-config/database.ts +1 -2
  76. package/lib/projects.ts +1 -2
  77. package/lib/recipes/settle.ts +2 -4
  78. package/lib/recipes/store.ts +2 -4
  79. package/lib/schedules/store.ts +2 -4
  80. package/lib/search/conversations/backfill.ts +2 -4
  81. package/lib/security/auth.ts +1 -2
  82. package/lib/security/passkeys.ts +1 -2
  83. package/lib/security/presence.ts +1 -7
  84. package/lib/session-artifacts/types.ts +1 -8
  85. package/lib/session-audit/transcript.ts +1 -3
  86. package/lib/session-logs/index.ts +1 -2
  87. package/lib/sessions/launch.ts +1 -2
  88. package/lib/share/pdf.ts +32 -1
  89. package/lib/share/snapshot.ts +1 -7
  90. package/lib/share/store.ts +1 -2
  91. package/lib/sidebar/orchestrator-row.ts +1 -6
  92. package/lib/sidebar/row-menu.ts +1 -2
  93. package/lib/sidebar/shelves.ts +1 -6
  94. package/lib/simulator/index.ts +1 -2
  95. package/lib/simulator/lease.ts +1 -5
  96. package/lib/stacks/index.ts +1 -1
  97. package/lib/stacks/plan.ts +1 -1
  98. package/lib/stacks/restack.ts +1 -2
  99. package/lib/stacks/tick.ts +1 -2
  100. package/lib/status/socket.ts +1 -2
  101. package/lib/tasks/contract/authorize.ts +1 -2
  102. package/lib/tasks/contract/plan.ts +1 -2
  103. package/lib/tasks/contract/sync.ts +1 -2
  104. package/lib/tasks/holds.ts +1 -5
  105. package/lib/tasks/index.ts +1 -2
  106. package/lib/tasks/loop-types.ts +1 -6
  107. package/lib/tasks/move-guard.ts +1 -2
  108. package/lib/tasks/move.ts +1 -2
  109. package/lib/tasks/moved-away.ts +1 -2
  110. package/lib/tasks/pause-hold.ts +1 -2
  111. package/lib/tasks/pause.ts +3 -6
  112. package/lib/tasks/pr-activity.ts +1 -3
  113. package/lib/tasks/queue-rows.ts +2 -4
  114. package/lib/tasks/queue-types.ts +1 -5
  115. package/lib/tasks/queue.ts +1 -2
  116. package/lib/tasks/refresh-base.ts +1 -2
  117. package/lib/tasks/setup.ts +9 -2
  118. package/lib/tasks/start.ts +2 -4
  119. package/lib/tasks/starter.ts +1 -2
  120. package/lib/tasks/state.ts +1 -6
  121. package/lib/theme-config.ts +1 -8
  122. package/lib/updates/banner.ts +1 -6
  123. package/lib/updates/run.ts +1 -2
  124. package/lib/usage/aggregate.ts +1 -1
  125. package/lib/usage/nightly.ts +1 -2
  126. package/lib/usage/rate-limits.ts +1 -2
  127. package/lib/usage/turns.ts +1 -2
  128. package/lib/usage/windows.ts +5 -7
  129. package/lib/workspaces.ts +1 -2
  130. package/lib/worktree-deps/clone.ts +10 -0
  131. package/lib/worktree-deps/fastcopy.ts +8 -1
  132. package/lib/worktree-deps/index.ts +14 -19
  133. package/lib/worktree-deps/install.ts +17 -24
  134. package/lib/worktree-sweep/databases.ts +0 -1
  135. package/package.json +10 -17
  136. package/scripts/check-native.mjs +86 -0
  137. package/scripts/code-health/prepass.ts +3 -3
  138. package/scripts/install-deps +24 -0
  139. package/scripts/lib/commands.sh +2 -2
  140. package/scripts/lib/deps-swap.sh +68 -0
  141. package/scripts/lib/deps-swap.test.ts +115 -0
  142. package/scripts/lib/pnpm.sh +36 -0
  143. package/scripts/lib/prerequisites.sh +9 -9
  144. package/scripts/publish +3 -2
  145. package/scripts/redeploy +25 -8
  146. package/scripts/setup.sh +4 -4
  147. package/tsconfig.json +1 -0
  148. package/scripts/ensure-native.mjs +0 -23
package/README.md CHANGED
@@ -43,7 +43,7 @@ and check on all of it from your phone.
43
43
 
44
44
  ## Install
45
45
 
46
- You need Node.js 20+, tmux, and at least one agent CLI (the installer offers to set up what's missing).
46
+ You need Node.js 22+, tmux, and at least one agent CLI (the installer offers to set up what's missing).
47
47
 
48
48
  ```bash
49
49
  npm install -g @saadnvd1/agent-os
@@ -74,7 +74,7 @@ npm run check # typecheck, lint, format and tests: what CI runs
74
74
  npm run screenshots # regenerate screenshots/ from a demo instance with fake data
75
75
  ```
76
76
 
77
- A pre-commit hook checks formatting, secrets and banned terms on what is staged. Route handlers may not call `*Sync` functions, and git runs only through `lib/git-exec.ts`. More in [docs/reference/development.md](docs/reference/development.md).
77
+ Dependencies install with pnpm, frozen to the lockfile, with a 7-day cooldown on new versions and an allowlist for install scripts: see [docs/reference/dependencies.md](docs/reference/dependencies.md) (it also covers adding a package that needs an install script and the override for an urgent security fix). A pre-commit hook checks formatting, secrets and banned terms on what is staged. Route handlers may not call `*Sync` functions, and git runs only through `lib/git-exec.ts`. More in [docs/reference/development.md](docs/reference/development.md).
78
78
 
79
79
  ## License
80
80
 
@@ -16,8 +16,7 @@ export async function GET(request: NextRequest) {
16
16
  }
17
17
 
18
18
  const project = queries.getProject(db).get(projectId) as
19
- | Project
20
- | undefined;
19
+ Project | undefined;
21
20
  if (!project) {
22
21
  return NextResponse.json({ error: "Project not found" }, { status: 404 });
23
22
  }
@@ -12,8 +12,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
12
12
  const { dsId } = await params;
13
13
 
14
14
  const existing = queries.getProjectDevServer(db).get(dsId) as
15
- | ProjectDevServer
16
- | undefined;
15
+ ProjectDevServer | undefined;
17
16
  if (!existing) {
18
17
  return NextResponse.json(
19
18
  { error: "Dev server config not found" },
@@ -49,8 +48,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
49
48
  const { dsId } = await params;
50
49
 
51
50
  const existing = queries.getProjectDevServer(db).get(dsId) as
52
- | ProjectDevServer
53
- | undefined;
51
+ ProjectDevServer | undefined;
54
52
  if (!existing) {
55
53
  return NextResponse.json(
56
54
  { error: "Dev server config not found" },
package/app/page.tsx CHANGED
@@ -237,8 +237,7 @@ function HomeContent() {
237
237
 
238
238
  // Get terminal for a pane, with fallback to first available
239
239
  const getTerminalWithFallback = useCallback(():
240
- | { terminal: TerminalHandle; paneId: string; tabId: string }
241
- | undefined => {
240
+ { terminal: TerminalHandle; paneId: string; tabId: string } | undefined => {
242
241
  debugLog(
243
242
  `getTerminalWithFallback called, total refs: ${terminalRefs.current.size}, focusedPaneId: ${focusedPaneId}`
244
243
  );
@@ -141,7 +141,7 @@ export function ArtifactsPanel({
141
141
  </button>
142
142
  )}
143
143
  </div>
144
- <div className="scrollbar-none mt-2.5 flex gap-1.5 overflow-x-auto">
144
+ <div className="mt-2.5 flex scrollbar-none gap-1.5 overflow-x-auto">
145
145
  {(Object.keys(FILTER_LABELS) as ArtifactFilter[])
146
146
  .filter((f) => f === "all" || tally[f])
147
147
  .map((f) => (
@@ -41,7 +41,7 @@ export function FileTabs({
41
41
  return (
42
42
  <div
43
43
  ref={scrollRef}
44
- className="bg-muted/30 scrollbar-none flex items-center gap-0.5 overflow-x-auto px-1"
44
+ className="bg-muted/30 flex scrollbar-none items-center gap-0.5 overflow-x-auto px-1"
45
45
  >
46
46
  {files.map((file) => {
47
47
  const isActive = file.path === activeFilePath;
@@ -102,7 +102,7 @@ export function DesktopTabBar({
102
102
  return (
103
103
  <div
104
104
  className={cn(
105
- "scrollbar-none @container/bar flex items-center gap-1 overflow-x-auto px-1.5 py-1.5 transition-colors",
105
+ "@container/bar flex scrollbar-none items-center gap-1 overflow-x-auto px-1.5 py-1.5 transition-colors",
106
106
  "shadow-[inset_0_-1px_0_hsl(var(--foreground)/0.06)]",
107
107
  isFocused ? "bg-foreground/[0.025]" : "bg-transparent"
108
108
  )}
@@ -110,7 +110,7 @@ export function DesktopTabBar({
110
110
  {leading}
111
111
 
112
112
  {/* Tabs */}
113
- <div className="scrollbar-none flex min-w-28 flex-1 items-center gap-0.5 overflow-x-auto">
113
+ <div className="flex min-w-28 flex-1 scrollbar-none items-center gap-0.5 overflow-x-auto">
114
114
  {tabs.map((tab) => (
115
115
  <div
116
116
  key={tab.id}
@@ -37,14 +37,33 @@ export function QuickSwitcher({
37
37
  currentSessionId,
38
38
  activeSessionWorkingDir,
39
39
  }: QuickSwitcherProps) {
40
- const [mode, setMode] = useState<"sessions" | "code">("sessions");
41
- const [query, setQuery] = useState("");
40
+ const [chosenMode, setMode] = useState<"sessions" | "code">("sessions");
41
+ const [query, setQueryState] = useState("");
42
42
  const [selectedIndex, setSelectedIndex] = useState(0);
43
43
  const inputRef = useRef<HTMLInputElement>(null);
44
44
 
45
45
  // Check if ripgrep is available
46
46
  const { data: ripgrepAvailable } = useRipgrepAvailable();
47
47
 
48
+ // Code search needs ripgrep
49
+ const mode = ripgrepAvailable === false ? "sessions" : chosenMode;
50
+
51
+ const setQuery = (next: string) => {
52
+ setQueryState(next);
53
+ setSelectedIndex(0);
54
+ };
55
+
56
+ // Reset state when dialog opens
57
+ const [wasOpen, setWasOpen] = useState(open);
58
+ if (open !== wasOpen) {
59
+ setWasOpen(open);
60
+ if (open) {
61
+ setMode("sessions");
62
+ setQueryState("");
63
+ setSelectedIndex(0);
64
+ }
65
+ }
66
+
48
67
  // Filter sessions based on search query
49
68
  const filteredSessions = sessions.filter((session) => {
50
69
  if (!query) return true;
@@ -56,28 +75,12 @@ export function QuickSwitcher({
56
75
  );
57
76
  });
58
77
 
59
- // Reset state when dialog opens
60
78
  useEffect(() => {
61
- if (open) {
62
- setMode("sessions");
63
- setQuery("");
64
- setSelectedIndex(0);
65
- setTimeout(() => inputRef.current?.focus(), 50);
66
- }
79
+ if (!open) return;
80
+ const t = setTimeout(() => inputRef.current?.focus(), 50);
81
+ return () => clearTimeout(t);
67
82
  }, [open]);
68
83
 
69
- // Force sessions mode if ripgrep is not available
70
- useEffect(() => {
71
- if (ripgrepAvailable === false && mode === "code") {
72
- setMode("sessions");
73
- }
74
- }, [ripgrepAvailable, mode]);
75
-
76
- // Reset selected index when filtered results change
77
- useEffect(() => {
78
- setSelectedIndex(0);
79
- }, [query]);
80
-
81
84
  // Handle keyboard navigation
82
85
  const handleKeyDown = useCallback(
83
86
  (e: React.KeyboardEvent) => {
@@ -32,7 +32,6 @@ import { selectionActions } from "@/stores/sessionSelection";
32
32
  import { schedulesUiActions } from "@/stores/schedulesUi";
33
33
  import { recipesUiActions } from "@/stores/recipesUi";
34
34
  import { rowGroup } from "@/lib/recipes/chip";
35
- import { finishActions } from "@/lib/sidebar/row-actions";
36
35
  import {
37
36
  useArchiveAction,
38
37
  useDoneAction,
@@ -3,7 +3,13 @@ import { act, createElement } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
- for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
6
+ for (const key of [
7
+ "Event",
8
+ "CustomEvent",
9
+ "FocusEvent",
10
+ "HTMLButtonElement",
11
+ "HTMLFormElement",
12
+ ])
7
13
  Object.defineProperty(globalThis, key, {
8
14
  value: (window as unknown as Record<string, unknown>)[key],
9
15
  configurable: true,
@@ -4,7 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  // Radix dispatches its own events; they must be jsdom's, not Node's.
7
- for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
7
+ for (const key of [
8
+ "Event",
9
+ "CustomEvent",
10
+ "FocusEvent",
11
+ "HTMLButtonElement",
12
+ "HTMLFormElement",
13
+ ])
8
14
  Object.defineProperty(globalThis, key, {
9
15
  value: (window as unknown as Record<string, unknown>)[key],
10
16
  configurable: true,
@@ -380,7 +380,7 @@ export function TerminalToolbar({
380
380
  onInsert={sendText}
381
381
  />
382
382
  <div
383
- className="bg-background/95 border-border scrollbar-none flex items-center gap-1 overflow-x-auto border-t px-2 py-1.5 backdrop-blur"
383
+ className="bg-background/95 border-border flex scrollbar-none items-center gap-1 overflow-x-auto border-t px-2 py-1.5 backdrop-blur"
384
384
  onTouchEnd={(e) => e.stopPropagation()}
385
385
  >
386
386
  {/* Mic button */}
@@ -295,7 +295,7 @@ function TerminalShortcutsBar({
295
295
  onClose={() => setShowPasteModal(false)}
296
296
  onPaste={handleModalPaste}
297
297
  />
298
- <div className="scrollbar-none flex items-center gap-1.5 overflow-x-auto px-2 py-1.5">
298
+ <div className="flex scrollbar-none items-center gap-1.5 overflow-x-auto px-2 py-1.5">
299
299
  {/* Paste button */}
300
300
  <FastButton
301
301
  onPress={handlePaste}
@@ -19,13 +19,7 @@ import {
19
19
  import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
20
20
 
21
21
  export type WorkerStatus =
22
- | "pending"
23
- | "running"
24
- | "waiting"
25
- | "idle"
26
- | "completed"
27
- | "failed"
28
- | "dead";
22
+ "pending" | "running" | "waiting" | "idle" | "completed" | "failed" | "dead";
29
23
 
30
24
  export interface WorkerInfo {
31
25
  id: string;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useState } from "react";
4
4
  import {
5
5
  Dialog,
6
6
  DialogContent,
@@ -28,9 +28,11 @@ export function WorkspaceNameDialog({
28
28
  onClose,
29
29
  }: WorkspaceNameDialogProps) {
30
30
  const [name, setName] = useState(initialName);
31
- useEffect(() => {
31
+ const [synced, setSynced] = useState({ open, initialName });
32
+ if (synced.open !== open || synced.initialName !== initialName) {
33
+ setSynced({ open, initialName });
32
34
  if (open) setName(initialName);
33
- }, [open, initialName]);
35
+ }
34
36
 
35
37
  return (
36
38
  <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@@ -33,12 +33,7 @@ import { cn } from "@/lib/utils";
33
33
 
34
34
  export type AButtonSize = "sm" | "md" | "lg";
35
35
  export type AButtonVariant =
36
- | "default"
37
- | "ghost"
38
- | "outline"
39
- | "secondary"
40
- | "destructive"
41
- | "link";
36
+ "default" | "ghost" | "outline" | "secondary" | "destructive" | "link";
42
37
 
43
38
  export interface AButtonProps extends Omit<
44
39
  React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -33,11 +33,7 @@ import { cn } from "@/lib/utils";
33
33
 
34
34
  export type AIconButtonSize = "sm" | "md" | "lg";
35
35
  export type AIconButtonHighlight =
36
- | "blue"
37
- | "green"
38
- | "red"
39
- | "orange"
40
- | "purple";
36
+ "blue" | "green" | "red" | "orange" | "purple";
41
37
 
42
38
  export interface AIconButtonProps extends Omit<
43
39
  React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -27,12 +27,7 @@ import {
27
27
  import { useViewport } from "@/hooks/useViewport";
28
28
 
29
29
  export type ViewMode =
30
- | "terminal"
31
- | "files"
32
- | "git"
33
- | "workers"
34
- | "log"
35
- | "artifacts";
30
+ "terminal" | "files" | "git" | "workers" | "log" | "artifacts";
36
31
 
37
32
  export interface PaneViewState {
38
33
  viewMode: ViewMode;
@@ -86,8 +86,7 @@ export function useLhWorkspaces(enabled: boolean) {
86
86
  }
87
87
 
88
88
  export type WorkspaceLinkTarget =
89
- | { lhWorkspaceId: string }
90
- | { create: true; name?: string };
89
+ { lhWorkspaceId: string } | { create: true; name?: string };
91
90
 
92
91
  export function useLinkWorkspace() {
93
92
  return useInvalidating(
@@ -120,8 +119,7 @@ export function useWorkspaceBoards(workspaceId: string | null) {
120
119
  }
121
120
 
122
121
  export type BoardLinkTarget =
123
- | { boardId: string }
124
- | { create: true; title?: string };
122
+ { boardId: string } | { create: true; title?: string };
125
123
 
126
124
  export function useLinkBoard() {
127
125
  return useInvalidating(
@@ -32,13 +32,14 @@ export function useDirectoryBrowser(options: UseDirectoryBrowserOptions = {}) {
32
32
  const currentPath = data?.resolvedPath || requestedPath;
33
33
 
34
34
  // Filter and sort files from query data
35
+ const dataFiles = data?.files;
35
36
  const files = useMemo(() => {
36
- if (!data?.files) return [];
37
+ if (!dataFiles) return [];
37
38
  const items = filterRef.current
38
- ? data.files.filter(filterRef.current)
39
- : data.files;
39
+ ? dataFiles.filter(filterRef.current)
40
+ : dataFiles;
40
41
  return sortFiles(items);
41
- }, [data?.files]);
42
+ }, [dataFiles]);
42
43
 
43
44
  const filteredFiles = useMemo(
44
45
  () =>
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef } from "react";
1
+ import { useState, useEffect } from "react";
2
2
 
3
3
  /**
4
4
  * Hook for smooth drawer enter animations.
@@ -6,22 +6,19 @@ import { useState, useEffect, useRef } from "react";
6
6
  */
7
7
  export function useDrawerAnimation(open: boolean) {
8
8
  const [isAnimatingIn, setIsAnimatingIn] = useState(false);
9
- const hasAnimated = useRef(false);
10
9
 
11
10
  useEffect(() => {
12
- if (open && !hasAnimated.current) {
13
- hasAnimated.current = true;
14
- requestAnimationFrame(() => {
15
- requestAnimationFrame(() => {
16
- setIsAnimatingIn(true);
17
- });
18
- });
19
- }
20
- if (!open) {
21
- hasAnimated.current = false;
11
+ if (!open) return;
12
+ let inner = 0;
13
+ const outer = requestAnimationFrame(() => {
14
+ inner = requestAnimationFrame(() => setIsAnimatingIn(true));
15
+ });
16
+ return () => {
17
+ cancelAnimationFrame(outer);
18
+ cancelAnimationFrame(inner);
22
19
  setIsAnimatingIn(false);
23
- }
20
+ };
24
21
  }, [open]);
25
22
 
26
- return isAnimatingIn;
23
+ return open && isAnimatingIn;
27
24
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState, useCallback, useEffect, type RefObject } from "react";
3
+ import { useState, useCallback, type RefObject } from "react";
4
4
 
5
5
  interface UseFileDropOptions {
6
6
  /** Disable drop handling (e.g., while uploading) */
@@ -29,11 +29,7 @@ export function useFileDrop(
29
29
  const [isDragging, setIsDragging] = useState(false);
30
30
 
31
31
  // Reset drag state when disabled
32
- useEffect(() => {
33
- if (options?.disabled) {
34
- setIsDragging(false);
35
- }
36
- }, [options?.disabled]);
32
+ if (options?.disabled && isDragging) setIsDragging(false);
37
33
 
38
34
  const handleDragOver = useCallback(
39
35
  (e: React.DragEvent) => {
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useState, useCallback, useEffect } from "react";
3
+ import { useCallback } from "react";
4
+ import { useStoredFlag } from "./useStoredFlag";
4
5
 
5
6
  const STORAGE_KEY = "agentOS-keybar-visible";
6
7
 
@@ -9,34 +10,14 @@ const STORAGE_KEY = "agentOS-keybar-visible";
9
10
  * Default: hidden on mobile to maximize terminal space.
10
11
  */
11
12
  export function useKeybarVisibility() {
12
- const [isVisible, setIsVisible] = useState(false);
13
-
14
- // Load persisted state on mount
15
- useEffect(() => {
16
- if (typeof window === "undefined") return;
17
- const stored = localStorage.getItem(STORAGE_KEY);
18
- if (stored === "true") {
19
- setIsVisible(true);
20
- }
21
- }, []);
22
-
23
- const toggle = useCallback(() => {
24
- setIsVisible((prev) => {
25
- const next = !prev;
26
- localStorage.setItem(STORAGE_KEY, String(next));
27
- return next;
28
- });
29
- }, []);
30
-
31
- const show = useCallback(() => {
32
- setIsVisible(true);
33
- localStorage.setItem(STORAGE_KEY, "true");
34
- }, []);
35
-
36
- const hide = useCallback(() => {
37
- setIsVisible(false);
38
- localStorage.setItem(STORAGE_KEY, "false");
39
- }, []);
13
+ const [isVisible, setVisible] = useStoredFlag(STORAGE_KEY, false);
14
+
15
+ const toggle = useCallback(
16
+ () => setVisible(!isVisible),
17
+ [isVisible, setVisible]
18
+ );
19
+ const show = useCallback(() => setVisible(true), [setVisible]);
20
+ const hide = useCallback(() => setVisible(false), [setVisible]);
40
21
 
41
22
  return { isVisible, toggle, show, hide };
42
23
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useState, useCallback, useEffect } from "react";
3
+ import { useCallback } from "react";
4
+ import { useStoredFlag } from "./useStoredFlag";
4
5
 
5
6
  const STORAGE_KEY = "agentOS-sidebar-pinned";
6
7
 
@@ -11,29 +12,12 @@ const STORAGE_KEY = "agentOS-sidebar-pinned";
11
12
  * reveals on hover near the left edge.
12
13
  */
13
14
  export function useSidebarPinned() {
14
- const [isPinned, setIsPinned] = useState(true);
15
+ const [isPinned, setPinned] = useStoredFlag(STORAGE_KEY, true);
15
16
 
16
- // Load persisted state on mount
17
- useEffect(() => {
18
- if (typeof window === "undefined") return;
19
- const stored = localStorage.getItem(STORAGE_KEY);
20
- if (stored === "false") {
21
- setIsPinned(false);
22
- }
23
- }, []);
24
-
25
- const setPinned = useCallback((pinned: boolean) => {
26
- setIsPinned(pinned);
27
- localStorage.setItem(STORAGE_KEY, String(pinned));
28
- }, []);
29
-
30
- const togglePin = useCallback(() => {
31
- setIsPinned((prev) => {
32
- const next = !prev;
33
- localStorage.setItem(STORAGE_KEY, String(next));
34
- return next;
35
- });
36
- }, []);
17
+ const togglePin = useCallback(
18
+ () => setPinned(!isPinned),
19
+ [isPinned, setPinned]
20
+ );
37
21
 
38
22
  return { isPinned, togglePin, setPinned };
39
23
  }
@@ -1,6 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useState, useRef, useCallback, useEffect } from "react";
3
+ import {
4
+ useState,
5
+ useRef,
6
+ useCallback,
7
+ useEffect,
8
+ useSyncExternalStore,
9
+ } from "react";
4
10
 
5
11
  // Web Speech API types
6
12
  interface SpeechRecognitionEvent extends Event {
@@ -46,19 +52,27 @@ declare global {
46
52
  }
47
53
  }
48
54
 
55
+ const noopSubscribe = () => () => {};
56
+
57
+ function getSpeechRecognitionAPI() {
58
+ return window.SpeechRecognition || window.webkitSpeechRecognition;
59
+ }
60
+
49
61
  export function useSpeechRecognition(
50
62
  onTranscript: (text: string, isFinal: boolean) => void
51
63
  ) {
52
64
  const [isListening, setIsListening] = useState(false);
53
- const [isSupported, setIsSupported] = useState(false);
65
+ const isSupported = useSyncExternalStore(
66
+ noopSubscribe,
67
+ () => !!getSpeechRecognitionAPI(),
68
+ () => false
69
+ );
54
70
  const recognitionRef = useRef<SpeechRecognition | null>(null);
55
71
  const lastTranscriptRef = useRef("");
56
72
 
57
73
  useEffect(() => {
58
74
  // Check for browser support
59
- const SpeechRecognitionAPI =
60
- window.SpeechRecognition || window.webkitSpeechRecognition;
61
- setIsSupported(!!SpeechRecognitionAPI);
75
+ const SpeechRecognitionAPI = getSpeechRecognitionAPI();
62
76
 
63
77
  if (SpeechRecognitionAPI) {
64
78
  const recognition = new SpeechRecognitionAPI();
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useCallback, useSyncExternalStore } from "react";
4
+
5
+ const listeners = new Set<() => void>();
6
+
7
+ function subscribe(fn: () => void) {
8
+ listeners.add(fn);
9
+ window.addEventListener("storage", fn);
10
+ return () => {
11
+ listeners.delete(fn);
12
+ window.removeEventListener("storage", fn);
13
+ };
14
+ }
15
+
16
+ function read(key: string): string | null {
17
+ try {
18
+ return localStorage.getItem(key);
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // A boolean kept in localStorage, shared by every caller on the page. The
25
+ // server and the hydrating render see `fallback`; the stored value follows.
26
+ export function useStoredFlag(
27
+ key: string,
28
+ fallback: boolean
29
+ ): [boolean, (next: boolean) => void] {
30
+ const value = useSyncExternalStore(
31
+ subscribe,
32
+ () => read(key),
33
+ () => null
34
+ );
35
+ const set = useCallback(
36
+ (next: boolean) => {
37
+ try {
38
+ localStorage.setItem(key, String(next));
39
+ } catch {
40
+ // Storage unavailable: the choice lasts until reload.
41
+ }
42
+ listeners.forEach((fn) => fn());
43
+ },
44
+ [key]
45
+ );
46
+ return [value === null ? fallback : value === "true", set];
47
+ }
@@ -1,28 +1,22 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect } from "react";
3
+ import { useSyncExternalStore } from "react";
4
+ import { useMediaQuery } from "./useMediaQuery";
5
+
6
+ const noop = () => () => {};
4
7
 
5
8
  /**
6
9
  * Mobile-first viewport detection hook
7
10
  * Breakpoint: 768px (md in Tailwind)
8
11
  */
9
12
  export function useViewport() {
10
- const [isMobile, setIsMobile] = useState(false);
11
- const [isHydrated, setIsHydrated] = useState(false);
12
-
13
- useEffect(() => {
14
- const checkViewport = () => {
15
- setIsMobile(window.innerWidth < 768);
16
- };
17
-
18
- // Initial check
19
- checkViewport();
20
- setIsHydrated(true);
21
-
22
- // Listen for resize
23
- window.addEventListener("resize", checkViewport);
24
- return () => window.removeEventListener("resize", checkViewport);
25
- }, []);
13
+ const isMobile = useMediaQuery("(max-width: 767.98px)");
14
+ // False on the server and while hydrating, true after.
15
+ const isHydrated = useSyncExternalStore(
16
+ noop,
17
+ () => true,
18
+ () => false
19
+ );
26
20
 
27
21
  return {
28
22
  isMobile,
@@ -14,13 +14,7 @@ import { markAllStatusDirty } from "../status/dirty";
14
14
  export const NO_APPROVALS_KEY = "approvals.none";
15
15
 
16
16
  export type AuditSource =
17
- | "ask"
18
- | "brake"
19
- | "plan"
20
- | "chat"
21
- | "terminal"
22
- | "merge-approval"
23
- | "mode";
17
+ "ask" | "brake" | "plan" | "chat" | "terminal" | "merge-approval" | "mode";
24
18
 
25
19
  export interface AuditRow {
26
20
  id: number;