@saadnvd1/agent-os 0.3.833 → 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.
- package/app/api/fresh-start/route.test.ts +33 -0
- package/app/api/fresh-start/route.ts +3 -0
- package/app/api/sessions/route.ts +7 -1
- package/app/globals.css +14 -0
- package/components/Chat/ChatPanel.tsx +41 -29
- package/components/Chat/Forks.tsx +16 -1
- package/components/Chat/Messages.tsx +14 -0
- package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
- package/components/Chat/composer/ComposerBody.tsx +10 -1
- package/components/Chat/composer/ForkHeader.tsx +26 -15
- package/components/Chat/fork-draft.test.ts +57 -0
- package/components/Chat/fork-draft.ts +34 -0
- package/components/Devices/DeviceRow.tsx +83 -69
- package/components/Devices/PasskeysSection.test.ts +89 -0
- package/components/Devices/PasskeysSection.tsx +36 -26
- package/components/Health/HealthPanel.tsx +41 -16
- package/components/Merge/GlobalMergeSection.tsx +2 -5
- package/components/Merge/MergeSettingsFields.tsx +39 -77
- package/components/Merge/ProjectMergeSection.tsx +2 -6
- package/components/Merge/WorktreeSweepSection.tsx +40 -14
- package/components/Models/AgentRoleCard.tsx +61 -66
- package/components/Models/AgentRolesSection.tsx +5 -8
- package/components/Models/ModelsSection.tsx +7 -18
- package/components/Models/PriceInput.tsx +59 -0
- package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
- package/components/Settings/AppWideLimits.tsx +61 -0
- package/components/Settings/ContextSection.tsx +96 -0
- package/components/Settings/ExperimentsSection.test.ts +9 -7
- package/components/Settings/ExperimentsSection.tsx +22 -23
- package/components/Settings/InlineConfirm.tsx +51 -0
- package/components/Settings/OwnerNameRow.tsx +10 -40
- package/components/Settings/PickerSetting.tsx +59 -0
- package/components/Settings/SettingRow.tsx +131 -0
- package/components/Settings/SettingsDialog.test.ts +1 -0
- package/components/Settings/SettingsDialog.tsx +47 -2
- package/components/Settings/settings-fields.test.ts +103 -0
- package/components/Settings/settings-fields.tsx +427 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +21 -54
- package/components/Workspaces/McpServersForm.tsx +20 -19
- package/components/Workspaces/ProjectTiersForm.tsx +10 -8
- package/components/Workspaces/RulesForm.test.ts +127 -0
- package/components/Workspaces/RulesForm.tsx +54 -126
- package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
- package/components/Workspaces/WorkspaceSettings.tsx +61 -24
- package/content/whats-new.json +32 -0
- package/data/fresh-start/index.ts +1 -1
- package/data/workspaces/index.ts +9 -0
- package/hooks/useAutoSave.test.ts +116 -0
- package/hooks/useAutoSave.ts +98 -0
- package/lib/away/window-limits.ts +3 -1
- package/lib/chat/refresh.ts +10 -6
- package/lib/chat/socket.ts +7 -3
- package/lib/chat/stall-limits.ts +3 -1
- package/lib/chat/start-error.ts +13 -0
- package/lib/chat/worker/cwd.ts +77 -0
- package/lib/chat/worker/host.ts +11 -2
- package/lib/chat/worker/main.ts +11 -2
- package/lib/db/types.ts +2 -0
- package/lib/fork/note.ts +6 -0
- package/lib/fork/start.ts +6 -2
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +14 -1
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/package.json +1 -1
- package/components/Away/AwayWindowForm.tsx +0 -91
- package/components/Workspaces/FreshStartForm.tsx +0 -177
- package/components/Workspaces/StallForm.tsx +0 -101
- package/components/Workspaces/TaskLimitForm.tsx +0 -84
- package/components/Workspaces/fresh-start.ts +0 -19
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Radix dispatches its own events; they must be jsdom's, not Node's.
|
|
7
|
+
for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
|
|
8
|
+
Object.defineProperty(globalThis, key, {
|
|
9
|
+
value: (window as unknown as Record<string, unknown>)[key],
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
});
|
|
13
|
+
(
|
|
14
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
15
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
|
|
17
|
+
|
|
18
|
+
const { NumberSetting } = await import("./settings-fields");
|
|
19
|
+
|
|
20
|
+
let root: Root;
|
|
21
|
+
let host: HTMLElement;
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await act(async () => root.unmount());
|
|
24
|
+
host.remove();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function mount(save: (n: number) => Promise<unknown>) {
|
|
28
|
+
host = document.createElement("div");
|
|
29
|
+
document.body.appendChild(host);
|
|
30
|
+
root = createRoot(host);
|
|
31
|
+
await act(async () =>
|
|
32
|
+
root.render(
|
|
33
|
+
createElement(NumberSetting, {
|
|
34
|
+
id: "n",
|
|
35
|
+
label: "Days",
|
|
36
|
+
value: 7,
|
|
37
|
+
defaultValue: 7,
|
|
38
|
+
min: 1,
|
|
39
|
+
max: 30,
|
|
40
|
+
onSave: save,
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
return host.querySelector("input")!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// React tracks the value; set it the way the browser does, then blur.
|
|
48
|
+
const type = async (input: HTMLInputElement, text: string) => {
|
|
49
|
+
const set = Object.getOwnPropertyDescriptor(
|
|
50
|
+
HTMLInputElement.prototype,
|
|
51
|
+
"value"
|
|
52
|
+
)!.set!;
|
|
53
|
+
await act(async () => {
|
|
54
|
+
set.call(input, text);
|
|
55
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const blur = (input: HTMLInputElement) =>
|
|
59
|
+
act(async () => {
|
|
60
|
+
input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("NumberSetting", () => {
|
|
64
|
+
it("keeps an invalid value in the field with a message and sends nothing", async () => {
|
|
65
|
+
const save = vi.fn().mockResolvedValue(undefined);
|
|
66
|
+
const input = await mount(save);
|
|
67
|
+
await type(input, "40");
|
|
68
|
+
await blur(input);
|
|
69
|
+
expect(save).not.toHaveBeenCalled();
|
|
70
|
+
expect(input.value).toBe("40");
|
|
71
|
+
expect(host.textContent).toContain("Enter a whole number from 1 to 30.");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("saves a valid value on blur", async () => {
|
|
75
|
+
const save = vi.fn().mockResolvedValue(undefined);
|
|
76
|
+
const input = await mount(save);
|
|
77
|
+
await type(input, "14");
|
|
78
|
+
await blur(input);
|
|
79
|
+
expect(save).toHaveBeenCalledWith(14);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reverts on Escape without saving", async () => {
|
|
83
|
+
const save = vi.fn().mockResolvedValue(undefined);
|
|
84
|
+
const input = await mount(save);
|
|
85
|
+
await type(input, "40");
|
|
86
|
+
await act(async () => {
|
|
87
|
+
input.dispatchEvent(
|
|
88
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
expect(input.value).toBe("7");
|
|
92
|
+
expect(save).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("goes back to the saved value when the server rejects", async () => {
|
|
96
|
+
const save = vi.fn().mockRejectedValue(new Error("no"));
|
|
97
|
+
const input = await mount(save);
|
|
98
|
+
await type(input, "14");
|
|
99
|
+
await blur(input);
|
|
100
|
+
expect(save).toHaveBeenCalledWith(14);
|
|
101
|
+
expect(input.value).toBe("7");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -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
|
-
|
|
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
|
|
58
|
-
<div className="
|
|
59
|
-
<div className="
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
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.
|
|
65
|
-
?
|
|
66
|
-
:
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
}
|