@saadnvd1/agent-os 0.3.836 → 0.3.839

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 (54) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/components/Devices/DeviceRow.tsx +83 -69
  4. package/components/Devices/PasskeysSection.test.ts +89 -0
  5. package/components/Devices/PasskeysSection.tsx +36 -26
  6. package/components/Health/HealthPanel.tsx +41 -16
  7. package/components/Merge/GlobalMergeSection.tsx +2 -5
  8. package/components/Merge/MergeSettingsFields.tsx +39 -77
  9. package/components/Merge/ProjectMergeSection.tsx +2 -6
  10. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  11. package/components/Models/AgentRoleCard.tsx +61 -66
  12. package/components/Models/AgentRolesSection.tsx +5 -8
  13. package/components/Models/ModelsSection.tsx +7 -18
  14. package/components/Models/PriceInput.tsx +59 -0
  15. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  16. package/components/Settings/AppWideLimits.tsx +61 -0
  17. package/components/Settings/ContextSection.tsx +96 -0
  18. package/components/Settings/ExperimentsSection.test.ts +9 -7
  19. package/components/Settings/ExperimentsSection.tsx +22 -23
  20. package/components/Settings/InlineConfirm.tsx +51 -0
  21. package/components/Settings/OwnerNameRow.tsx +10 -40
  22. package/components/Settings/PickerSetting.tsx +59 -0
  23. package/components/Settings/SettingRow.tsx +131 -0
  24. package/components/Settings/SettingsDialog.test.ts +1 -0
  25. package/components/Settings/SettingsDialog.tsx +47 -2
  26. package/components/Settings/settings-fields.test.ts +103 -0
  27. package/components/Settings/settings-fields.tsx +427 -0
  28. package/components/Shares/SharedLinksPanel.tsx +62 -53
  29. package/components/Workspaces/HqFolderForm.tsx +21 -54
  30. package/components/Workspaces/McpServersForm.tsx +20 -19
  31. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  32. package/components/Workspaces/RulesForm.test.ts +127 -0
  33. package/components/Workspaces/RulesForm.tsx +54 -126
  34. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  35. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  36. package/content/whats-new.json +16 -0
  37. package/data/fresh-start/index.ts +1 -1
  38. package/data/workspaces/index.ts +9 -0
  39. package/hooks/useAutoSave.test.ts +116 -0
  40. package/hooks/useAutoSave.ts +98 -0
  41. package/lib/away/window-limits.ts +3 -1
  42. package/lib/chat/refresh.ts +10 -6
  43. package/lib/chat/stall-limits.ts +3 -1
  44. package/lib/settings/defaults.ts +27 -0
  45. package/lib/settings/parse.ts +17 -0
  46. package/lib/settings-url.ts +1 -0
  47. package/lib/worktree-sweep/limits.ts +3 -0
  48. package/lib/worktree-sweep/settings.ts +4 -2
  49. package/package.json +1 -1
  50. package/components/Away/AwayWindowForm.tsx +0 -91
  51. package/components/Workspaces/FreshStartForm.tsx +0 -177
  52. package/components/Workspaces/StallForm.tsx +0 -101
  53. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  54. package/components/Workspaces/fresh-start.ts +0 -19
@@ -0,0 +1,427 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select";
13
+ import { Switch } from "@/components/ui/switch";
14
+ import { useAutoSave } from "@/hooks/useAutoSave";
15
+ import { parseSettingNumber } from "@/lib/settings/parse";
16
+ import { InlineConfirm } from "./InlineConfirm";
17
+ import { SettingRow } from "./SettingRow";
18
+
19
+ type Save<T> = (next: T) => Promise<unknown>;
20
+
21
+ const FIELD = "h-11 text-base md:h-9 md:text-sm";
22
+
23
+ /** A switch: saves at once. `confirm` makes turning it ON ask first. */
24
+ export function SwitchSetting({
25
+ id,
26
+ label,
27
+ help,
28
+ checked,
29
+ onSave,
30
+ defaultValue,
31
+ confirm,
32
+ confirmLabel = "Turn on",
33
+ undoable,
34
+ locked,
35
+ extra,
36
+ }: {
37
+ id: string;
38
+ label: string;
39
+ help?: string;
40
+ checked: boolean;
41
+ onSave: Save<boolean>;
42
+ defaultValue?: boolean;
43
+ confirm?: string;
44
+ confirmLabel?: string;
45
+ undoable?: boolean;
46
+ // Can't be changed from here.
47
+ locked?: boolean;
48
+ // Shown under the row (usage, links).
49
+ extra?: ReactNode;
50
+ }) {
51
+ const auto = useAutoSave({ saved: checked, save: onSave });
52
+ const [asking, setAsking] = useState(false);
53
+ return (
54
+ <SettingRow
55
+ id={id}
56
+ label={label}
57
+ help={locked ? `${help ?? ""} Can't be changed from here.`.trim() : help}
58
+ status={auto.status}
59
+ onUndo={auto.undo}
60
+ hint={
61
+ defaultValue === undefined
62
+ ? undefined
63
+ : `default ${defaultValue ? "on" : "off"}`
64
+ }
65
+ onReset={
66
+ defaultValue !== undefined && auto.value !== defaultValue
67
+ ? () => void auto.commit(defaultValue, { undoable: true })
68
+ : undefined
69
+ }
70
+ controlClassName="max-sm:col-span-1 max-sm:row-start-1 max-sm:mt-0"
71
+ control={
72
+ <Switch
73
+ id={id}
74
+ checked={auto.value}
75
+ disabled={locked}
76
+ aria-describedby={`${id}-hint`}
77
+ className="data-[state=checked]:bg-primary"
78
+ onCheckedChange={(on) => {
79
+ if (on && confirm) setAsking(true);
80
+ else void auto.commit(on, { undoable: undoable || !on });
81
+ }}
82
+ />
83
+ }
84
+ below={
85
+ (asking || extra) && (
86
+ <>
87
+ {extra}
88
+ {asking && confirm && (
89
+ <InlineConfirm
90
+ label={`Turn on ${label}`}
91
+ message={confirm}
92
+ confirmLabel={confirmLabel}
93
+ onConfirm={() => {
94
+ setAsking(false);
95
+ void auto.commit(true, { undoable: true });
96
+ }}
97
+ onCancel={() => setAsking(false)}
98
+ />
99
+ )}
100
+ </>
101
+ )
102
+ }
103
+ />
104
+ );
105
+ }
106
+
107
+ /**
108
+ * A number, saved on blur or Enter. Esc reverts; a bad value stays in the
109
+ * field with a message and nothing is sent. `scale` converts the stored value
110
+ * to what's typed (tokens to k). With `optional`, a switch turns it off
111
+ * (`off`) or on (`on`) and the input greys out, keeping its number.
112
+ */
113
+ export function NumberSetting({
114
+ id,
115
+ label,
116
+ help,
117
+ value,
118
+ onSave,
119
+ defaultValue,
120
+ min,
121
+ max,
122
+ integer = true,
123
+ unit,
124
+ short = "",
125
+ scale = 1,
126
+ optional,
127
+ emptyResets,
128
+ }: {
129
+ id: string;
130
+ label: string;
131
+ help?: string;
132
+ value: number;
133
+ onSave: Save<number>;
134
+ defaultValue: number;
135
+ min: number;
136
+ max?: number;
137
+ integer?: boolean;
138
+ unit?: string;
139
+ // The unit as it follows a number in the hint ("k").
140
+ short?: string;
141
+ scale?: number;
142
+ optional?: { off: number; on: number };
143
+ // Clearing the box resets to the default instead of being an error.
144
+ emptyResets?: boolean;
145
+ }) {
146
+ const auto = useAutoSave({ saved: value, save: onSave });
147
+ const [text, setText] = useState<string | null>(null);
148
+ const [error, setError] = useState<string | null>(null);
149
+ const off = optional ? auto.value === optional.off : false;
150
+ const shown = (off ? optional!.on : auto.value) / scale;
151
+ const defOff = optional ? defaultValue === optional.off : false;
152
+ const hint =
153
+ error === null
154
+ ? `default ${defOff ? "off" : `${defaultValue / scale}${short}`} · ${
155
+ max === undefined
156
+ ? `${min / scale}${short}+`
157
+ : `${min / scale}–${max / scale}${short}`
158
+ }`
159
+ : undefined;
160
+
161
+ const finish = () => {
162
+ if (text === null) return;
163
+ if (!text.trim() && emptyResets) {
164
+ setText(null);
165
+ void auto.commit(defaultValue, { undoable: true });
166
+ return;
167
+ }
168
+ const parsed = parseSettingNumber(text, {
169
+ min: min / scale,
170
+ max: max === undefined ? undefined : max / scale,
171
+ integer,
172
+ });
173
+ if ("error" in parsed) return setError(parsed.error);
174
+ setError(null);
175
+ setText(null);
176
+ const next = Math.round(parsed.value * scale * 1e6) / 1e6;
177
+ void auto.commit(next, {
178
+ undoable:
179
+ auto.value > 0 && Math.abs(next - auto.value) / auto.value > 0.5,
180
+ });
181
+ };
182
+
183
+ return (
184
+ <SettingRow
185
+ id={id}
186
+ label={label}
187
+ help={help}
188
+ status={auto.status}
189
+ error={error}
190
+ hint={hint}
191
+ onUndo={auto.undo}
192
+ onReset={
193
+ auto.value !== defaultValue
194
+ ? () => {
195
+ setText(null);
196
+ setError(null);
197
+ void auto.commit(defaultValue, { undoable: true });
198
+ }
199
+ : undefined
200
+ }
201
+ control={
202
+ <>
203
+ <Input
204
+ id={id}
205
+ type="text"
206
+ inputMode={integer ? "numeric" : "decimal"}
207
+ value={text ?? String(shown)}
208
+ disabled={off}
209
+ aria-invalid={!!error}
210
+ aria-describedby={`${id}-hint`}
211
+ onChange={(e) => {
212
+ setText(e.target.value);
213
+ if (error) setError(null);
214
+ }}
215
+ onBlur={finish}
216
+ onKeyDown={(e) => {
217
+ if (e.key === "Enter") e.currentTarget.blur();
218
+ if (e.key === "Escape") {
219
+ setText(null);
220
+ setError(null);
221
+ e.currentTarget.blur();
222
+ }
223
+ }}
224
+ className={`${FIELD} w-24 text-right tabular-nums`}
225
+ />
226
+ {unit && (
227
+ <span className="text-muted-foreground min-w-16 text-sm">
228
+ {unit}
229
+ </span>
230
+ )}
231
+ {optional && (
232
+ <Switch
233
+ aria-label={`${label} on`}
234
+ checked={!off}
235
+ onCheckedChange={(on) => {
236
+ setError(null);
237
+ setText(null);
238
+ void auto.commit(on ? optional.on : optional.off, {
239
+ undoable: !on,
240
+ });
241
+ }}
242
+ />
243
+ )}
244
+ </>
245
+ }
246
+ />
247
+ );
248
+ }
249
+
250
+ /** A choice, saved at once. */
251
+ export function SelectSetting({
252
+ id,
253
+ label,
254
+ help,
255
+ value,
256
+ options,
257
+ onSave,
258
+ defaultValue,
259
+ hint,
260
+ }: {
261
+ id: string;
262
+ label: string;
263
+ help?: string;
264
+ value: string;
265
+ options: { value: string; label: string }[];
266
+ onSave: Save<string>;
267
+ defaultValue?: string;
268
+ // Replaces "default <label>" (an empty string for none).
269
+ hint?: string;
270
+ }) {
271
+ const auto = useAutoSave({ saved: value, save: onSave });
272
+ const def = options.find((o) => o.value === defaultValue);
273
+ return (
274
+ <SettingRow
275
+ id={id}
276
+ label={label}
277
+ help={help}
278
+ status={auto.status}
279
+ onUndo={auto.undo}
280
+ hint={hint ?? (def ? `default ${def.label}` : undefined)}
281
+ onReset={
282
+ defaultValue !== undefined && auto.value !== defaultValue
283
+ ? () => void auto.commit(defaultValue, { undoable: true })
284
+ : undefined
285
+ }
286
+ control={
287
+ <Select value={auto.value} onValueChange={(v) => void auto.commit(v)}>
288
+ <SelectTrigger id={id} className={`${FIELD} w-44`}>
289
+ <SelectValue />
290
+ </SelectTrigger>
291
+ <SelectContent>
292
+ {options.map((o) => (
293
+ <SelectItem key={o.value} value={o.value}>
294
+ {o.label}
295
+ </SelectItem>
296
+ ))}
297
+ </SelectContent>
298
+ </Select>
299
+ }
300
+ />
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Text, saved on blur or Enter after `validate` (sync or async) says it's
306
+ * fine. With `secret` or `explicit`, nothing saves on blur: Enter or the
307
+ * button does (a save that asks for a passkey must be deliberate). `secret`
308
+ * also hides what's typed.
309
+ */
310
+ export function TextSetting({
311
+ id,
312
+ label,
313
+ help,
314
+ value,
315
+ onSave,
316
+ validate,
317
+ placeholder,
318
+ secret,
319
+ buttonLabel = "Save",
320
+ defaultValue = "",
321
+ maxLength,
322
+ explicit,
323
+ resettable = true,
324
+ after,
325
+ }: {
326
+ id: string;
327
+ label: string;
328
+ help?: string;
329
+ value: string;
330
+ onSave: Save<string>;
331
+ validate?: (text: string) => string | null | Promise<string | null>;
332
+ placeholder?: string;
333
+ secret?: boolean;
334
+ buttonLabel?: string;
335
+ defaultValue?: string;
336
+ maxLength?: number;
337
+ explicit?: boolean;
338
+ // Offer Reset while the value differs from `defaultValue`.
339
+ resettable?: boolean;
340
+ // Extra controls beside the input, such as a delete button.
341
+ after?: ReactNode;
342
+ }) {
343
+ const deliberate = secret || explicit;
344
+ const auto = useAutoSave({ saved: value, save: onSave });
345
+ const [text, setText] = useState<string | null>(null);
346
+ const [error, setError] = useState<string | null>(null);
347
+ const [checking, setChecking] = useState(false);
348
+
349
+ const finish = async () => {
350
+ if (text === null) return;
351
+ const next = text.trim();
352
+ if (next === auto.value) return setText(null);
353
+ setChecking(true);
354
+ const problem = (await validate?.(next)) ?? null;
355
+ setChecking(false);
356
+ if (problem) return setError(problem);
357
+ setError(null);
358
+ setText(null);
359
+ await auto.commit(next, { undoable: !next });
360
+ };
361
+ const dirty = text !== null && text.trim() !== auto.value;
362
+
363
+ return (
364
+ <SettingRow
365
+ id={id}
366
+ label={label}
367
+ help={help}
368
+ status={checking ? "saving" : auto.status}
369
+ error={error}
370
+ onUndo={auto.undo}
371
+ onReset={
372
+ resettable && !secret && auto.value !== defaultValue
373
+ ? () => {
374
+ setText(null);
375
+ setError(null);
376
+ void auto.commit(defaultValue, { undoable: true });
377
+ }
378
+ : undefined
379
+ }
380
+ control={
381
+ <>
382
+ <Input
383
+ id={id}
384
+ type={secret ? "password" : "text"}
385
+ autoComplete="off"
386
+ maxLength={maxLength}
387
+ value={text ?? (secret ? "" : auto.value)}
388
+ placeholder={placeholder}
389
+ aria-invalid={!!error}
390
+ aria-describedby={`${id}-hint`}
391
+ onChange={(e) => {
392
+ setText(e.target.value);
393
+ if (error) setError(null);
394
+ }}
395
+ onBlur={() => {
396
+ if (!deliberate) void finish();
397
+ }}
398
+ onKeyDown={(e) => {
399
+ if (e.key === "Enter") {
400
+ if (deliberate) void finish();
401
+ else e.currentTarget.blur();
402
+ }
403
+ if (e.key === "Escape") {
404
+ setText(null);
405
+ setError(null);
406
+ e.currentTarget.blur();
407
+ }
408
+ }}
409
+ className={`${FIELD} w-full sm:w-64`}
410
+ />
411
+ {deliberate && (
412
+ <Button
413
+ type="button"
414
+ size="sm"
415
+ className="h-11 sm:h-9"
416
+ disabled={!dirty || checking}
417
+ onClick={() => void finish()}
418
+ >
419
+ {buttonLabel}
420
+ </Button>
421
+ )}
422
+ {after}
423
+ </>
424
+ }
425
+ />
426
+ );
427
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { toast } from "sonner";
4
5
  import { Copy, Link2 } from "lucide-react";
5
6
  import { Button } from "@/components/ui/button";
@@ -11,6 +12,7 @@ import {
11
12
  } from "@/data/shares";
12
13
  import { useAllLiveShares, useEndLive, type LiveView } from "@/data/live";
13
14
  import { cn } from "@/lib/utils";
15
+ import { InlineConfirm } from "@/components/Settings/InlineConfirm";
14
16
  import { SessionAnchor } from "@/components/Chat/SessionAnchor";
15
17
 
16
18
  const when = (iso: string) =>
@@ -23,6 +25,7 @@ const when = (iso: string) =>
23
25
  function Row({ share }: { share: SharedLinkRow }) {
24
26
  const revoke = useRevokeShare();
25
27
  const live = share.status === "active";
28
+ const [revoking, setRevoking] = useState(false);
26
29
 
27
30
  const copy = async () => {
28
31
  try {
@@ -33,12 +36,7 @@ function Row({ share }: { share: SharedLinkRow }) {
33
36
  }
34
37
  };
35
38
  const onRevoke = () => {
36
- if (
37
- !window.confirm(
38
- "Revoke this link? The page is deleted from Connect and the link stops working."
39
- )
40
- )
41
- return;
39
+ setRevoking(false);
42
40
  revoke.mutate(share.id, {
43
41
  onError: (err) => toast.error((err as Error).message),
44
42
  });
@@ -54,57 +52,68 @@ function Row({ share }: { share: SharedLinkRow }) {
54
52
  );
55
53
 
56
54
  return (
57
- <li className="bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5 sm:flex-row sm:items-center">
58
- <div className="min-w-0 flex-1">
59
- <div className="truncate text-sm font-medium">{title}</div>
60
- <div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
61
- <span>{share.kind === "chat" ? "Chat" : "Page"}</span>
62
- {share.kind === "artifact" && (
55
+ <li className="bg-muted/40 rounded-lg px-3 py-2.5">
56
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
57
+ <div className="min-w-0 flex-1">
58
+ <div className="truncate text-sm font-medium">{title}</div>
59
+ <div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
60
+ <span>{share.kind === "chat" ? "Chat" : "Page"}</span>
61
+ {share.kind === "artifact" && (
62
+ <span>
63
+ {share.follow
64
+ ? `Follows latest${share.version ? ` · now v${share.version}` : ""}`
65
+ : `pinned${share.version ? ` v${share.version}` : ""}`}
66
+ </span>
67
+ )}
68
+ <span>Created {when(share.createdAt)}</span>
63
69
  <span>
64
- {share.follow
65
- ? `Follows latest${share.version ? ` · now v${share.version}` : ""}`
66
- : `pinned${share.version ? ` v${share.version}` : ""}`}
70
+ {share.status === "revoked"
71
+ ? "Revoked"
72
+ : share.status === "expired"
73
+ ? "Expired"
74
+ : share.expiresAt
75
+ ? `Expires ${when(share.expiresAt)}`
76
+ : "Never expires"}
67
77
  </span>
68
- )}
69
- <span>Created {when(share.createdAt)}</span>
70
- <span>
71
- {share.status === "revoked"
72
- ? "Revoked"
73
- : share.status === "expired"
74
- ? "Expired"
75
- : share.expiresAt
76
- ? `Expires ${when(share.expiresAt)}`
77
- : "Never expires"}
78
- </span>
79
- <span>
80
- {share.views === null
81
- ? "Views unavailable"
82
- : `${share.views} ${share.views === 1 ? "view" : "views"}`}
83
- </span>
78
+ <span>
79
+ {share.views === null
80
+ ? "Views unavailable"
81
+ : `${share.views} ${share.views === 1 ? "view" : "views"}`}
82
+ </span>
83
+ </div>
84
+ </div>
85
+ <div className="flex shrink-0 gap-2">
86
+ <Button
87
+ variant="outline"
88
+ className={cn("min-h-11 sm:min-h-8", !live && "invisible")}
89
+ onClick={copy}
90
+ disabled={!live}
91
+ >
92
+ <Copy className="h-4 w-4" />
93
+ Copy link
94
+ </Button>
95
+ <Button
96
+ variant="outline"
97
+ className={cn(
98
+ "text-destructive min-h-11 sm:min-h-8",
99
+ !live && "invisible"
100
+ )}
101
+ onClick={() => setRevoking(true)}
102
+ disabled={!live || revoke.isPending}
103
+ >
104
+ Revoke
105
+ </Button>
84
106
  </div>
85
107
  </div>
86
- <div className="flex shrink-0 gap-2">
87
- <Button
88
- variant="outline"
89
- className={cn("min-h-11 sm:min-h-8", !live && "invisible")}
90
- onClick={copy}
91
- disabled={!live}
92
- >
93
- <Copy className="h-4 w-4" />
94
- Copy link
95
- </Button>
96
- <Button
97
- variant="outline"
98
- className={cn(
99
- "text-destructive min-h-11 sm:min-h-8",
100
- !live && "invisible"
101
- )}
102
- onClick={onRevoke}
103
- disabled={!live || revoke.isPending}
104
- >
105
- Revoke
106
- </Button>
107
- </div>
108
+ {revoking && (
109
+ <InlineConfirm
110
+ label="Revoke link"
111
+ message="Revoke this link? The page is deleted from Connect and the link stops working."
112
+ confirmLabel="Revoke"
113
+ onConfirm={onRevoke}
114
+ onCancel={() => setRevoking(false)}
115
+ />
116
+ )}
108
117
  </li>
109
118
  );
110
119
  }
@@ -1,20 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { toast } from "sonner";
5
- import { Button } from "@/components/ui/button";
6
- import { Input } from "@/components/ui/input";
3
+ import { SettingSection } from "@/components/Settings/SettingRow";
4
+ import { TextSetting } from "@/components/Settings/settings-fields";
7
5
  import { useSetWorkspaceHq, useWorkspaceHqQuery } from "@/data/session-logs";
8
6
 
9
7
  // The git repo this workspace's session logs are written into and
10
- // committed to. Blank keeps them in AgentOS's own folder.
8
+ // committed to. Blank keeps them in AgentOS's own folder. The server checks
9
+ // the folder exists and is a fit repo before anything is saved; a refusal
10
+ // shows as a toast and the field goes back to the saved folder.
11
11
  export function HqFolderForm({ workspaceId }: { workspaceId: string }) {
12
12
  const { data, isPending, isError, error } = useWorkspaceHqQuery(workspaceId);
13
13
  const save = useSetWorkspaceHq(workspaceId);
14
- const [draft, setDraft] = useState<string | null>(null);
15
- const saved = data?.hq?.folder ?? "";
16
- const folder = draft ?? saved;
17
- const id = `hq-folder-${workspaceId}`;
18
14
 
19
15
  if (isPending)
20
16
  return <div className="bg-muted/60 h-11 animate-pulse rounded-md" />;
@@ -22,50 +18,21 @@ export function HqFolderForm({ workspaceId }: { workspaceId: string }) {
22
18
  return <p className="text-destructive text-xs">{error.message}</p>;
23
19
 
24
20
  return (
25
- <form
26
- onSubmit={(e) => {
27
- e.preventDefault();
28
- save.mutate(folder, {
29
- onSuccess: () => {
30
- setDraft(null);
31
- toast.success(
32
- folder.trim() ? "HQ folder saved" : "HQ folder cleared"
33
- );
34
- },
35
- onError: (err) => toast.error(err.message),
36
- });
37
- }}
38
- className="space-y-1.5"
39
- >
40
- <label htmlFor={id} className="text-sm">
41
- HQ folder
42
- </label>
43
- <div className="flex gap-2">
44
- <Input
45
- id={id}
46
- value={folder}
47
- onChange={(e) => setDraft(e.target.value)}
48
- placeholder="~/path/to/a/private/git/repo"
49
- className="h-11 flex-1 text-base md:h-9 md:text-sm"
50
- />
51
- <Button
52
- type="submit"
53
- size="sm"
54
- className="h-11 sm:h-9"
55
- disabled={folder === saved || save.isPending}
56
- >
57
- Save
58
- </Button>
59
- </div>
60
- <p className="text-muted-foreground text-xs">
61
- Session logs go to docs/sessions/&lt;project&gt;/ in this repo and are
62
- committed there, one file per commit.{" "}
63
- {data.hq?.visibility === "private"
64
- ? "The repo is private, so each commit is pushed."
65
- : data.hq
66
- ? "Commits stay local: the repo isn't a private GitHub repository."
67
- : "It must be a private git repo outside the workspace's projects."}
68
- </p>
69
- </form>
21
+ <SettingSection>
22
+ <TextSetting
23
+ id={`hq-folder-${workspaceId}`}
24
+ label="HQ folder"
25
+ help={`Session logs go to docs/sessions/<project>/ in this repo, one commit per file. ${
26
+ data.hq?.visibility === "private"
27
+ ? "The repo is private, so each commit is pushed."
28
+ : data.hq
29
+ ? "Commits stay local: the repo isn't a private GitHub repository."
30
+ : "It must be a private git repo outside the workspace's projects."
31
+ }`}
32
+ placeholder="~/path/to/a/private/git/repo"
33
+ value={data.hq?.folder ?? ""}
34
+ onSave={(folder) => save.mutateAsync(folder)}
35
+ />
36
+ </SettingSection>
70
37
  );
71
38
  }