@hienlh/ppm 0.17.38 → 0.17.40
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/CHANGELOG.md +15 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/{ai-resource-editor-ChT9FzxT.js → ai-resource-editor-bGSN2dFv.js} +1 -1
- package/dist/web/assets/{api-client-BgM59GxL.js → api-client-COQtP-dB.js} +1 -1
- package/dist/web/assets/api-client-RtNXvS0P.js +1 -0
- package/dist/web/assets/api-settings-De--HNHp.js +1 -0
- package/dist/web/assets/{api-settings-D108koFG.js → api-settings-Do0Z0IRN.js} +1 -1
- package/dist/web/assets/{api-themes-cPGCYGB_.js → api-themes-DXi8sz-9.js} +1 -1
- package/dist/web/assets/{audio-preview-CSK6nxNy.js → audio-preview-DnSpaaBX.js} +1 -1
- package/dist/web/assets/{chat-tab-DGM7js5J.js → chat-tab-Cvuo7XWm.js} +4 -4
- package/dist/web/assets/{code-editor-BvLxm1bf.js → code-editor-BvwCPxaD.js} +2 -2
- package/dist/web/assets/{conflict-editor-Bc063EfD.js → conflict-editor-LDIdkUJm.js} +1 -1
- package/dist/web/assets/{csv-preview-ft27dubA.js → csv-preview-47tAvhIq.js} +1 -1
- package/dist/web/assets/{database-viewer-Bdj1ferk.js → database-viewer-RkxcBpY3.js} +1 -1
- package/dist/web/assets/{diff-viewer-C46_ZovZ.js → diff-viewer-YTRJpdV6.js} +1 -1
- package/dist/web/assets/{docx-preview-CLDLTD8N.js → docx-preview-B4B4W5LU.js} +1 -1
- package/dist/web/assets/{extension-webview-BSvKnN1E.js → extension-webview-CB8-nHNA.js} +1 -1
- package/dist/web/assets/{git-log-panel-GP8XaTlA.js → git-log-panel-OXE-EtxM.js} +1 -1
- package/dist/web/assets/{glide-data-grid-On_pS9Y7.js → glide-data-grid-CCVPfJtk.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DmYt9GfT.js → group-chat-tab-D5rkf_zG.js} +1 -1
- package/dist/web/assets/{image-preview-BWNTrufr.js → image-preview-CenSD210.js} +1 -1
- package/dist/web/assets/index-BEB87kj8.css +2 -0
- package/dist/web/assets/index-bQy6gwMT.js +50 -0
- package/dist/web/assets/keybindings-store-Bk2X4njD.js +1 -0
- package/dist/web/assets/markdown-renderer-CAFmdvwK.js +1 -0
- package/dist/web/assets/{markdown-renderer-suZuQvya.js → markdown-renderer-CyzYLWuu.js} +1 -1
- package/dist/web/assets/notification-store-C9MS_FlD.js +1 -0
- package/dist/web/assets/{pdf-preview-DcP6Tj7N.js → pdf-preview-CsOL51Lb.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BOlXoo4R.js → postgres-viewer-Dtt-XLSP.js} +1 -1
- package/dist/web/assets/{project-store-8_AdCduJ.js → project-store-D0ALup9Z.js} +1 -1
- package/dist/web/assets/{settings-store-BhZ7VYJD.js → settings-store-BbFtHCGd.js} +2 -2
- package/dist/web/assets/settings-tab-BR9fp2pd.js +1 -0
- package/dist/web/assets/{sql-query-editor-h4zuID_d.js → sql-query-editor-BwquzTWY.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-D5X8iWUw.js → sqlite-viewer-YO4IEGzt.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-DL-viZmD.js → system-monitor-tab-C-WT_FG3.js} +1 -1
- package/dist/web/assets/terminal-tab-BqW8reuZ.js +3 -0
- package/dist/web/assets/{tool-cards-zK7OP3DS.js → tool-cards-DtF80k3f.js} +2 -2
- package/dist/web/assets/{use-blob-url-BYDGMRMU.js → use-blob-url-B3YovXF6.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-Bk-dETGV.js → use-monaco-theme-CF_57XHm.js} +1 -1
- package/dist/web/assets/{video-preview-C6bKCBvz.js → video-preview-OoDYJ8U4.js} +1 -1
- package/dist/web/index.html +6 -6
- package/dist/web/sw.js +1 -1
- package/package.json +2 -2
- package/src/server/index.ts +4 -0
- package/src/server/routes/oauth-loopback.ts +75 -0
- package/src/web/components/layout/dock-panel.tsx +8 -1
- package/src/web/components/layout/mobile-nav.tsx +16 -2
- package/src/web/components/terminal/terminal-finish-login.tsx +75 -0
- package/src/web/components/terminal/terminal-links-sheet.tsx +53 -30
- package/src/web/components/ui/mobile-bottom-sheet.tsx +29 -26
- package/src/web/hooks/use-chat.ts +9 -8
- package/src/web/hooks/use-terminal.ts +18 -7
- package/src/web/hooks/use-usage.ts +11 -10
- package/src/web/hooks/use-visual-viewport.ts +83 -0
- package/src/web/lib/api-client.ts +17 -6
- package/src/web/lib/oauth-loopback-url.ts +55 -0
- package/dist/web/assets/api-client-BwMdPtp6.js +0 -1
- package/dist/web/assets/api-settings-D2fY0GUc.js +0 -1
- package/dist/web/assets/index-Crnizc26.js +0 -50
- package/dist/web/assets/index-opWDpE6A.css +0 -2
- package/dist/web/assets/keybindings-store-B4EWnplE.js +0 -1
- package/dist/web/assets/markdown-renderer-Bhv5w9Op.js +0 -1
- package/dist/web/assets/notification-store-B3zEJT3q.js +0 -1
- package/dist/web/assets/settings-tab-BqJJqL1l.js +0 -1
- package/dist/web/assets/terminal-tab-BVW9MfC3.js +0 -3
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completes a CLI login that was started here and signed in on another device.
|
|
3
|
+
*
|
|
4
|
+
* The CLI is listening on a loopback port of the machine PPM runs on, but the
|
|
5
|
+
* browser that signed in was redirected to *its own* loopback address, so the
|
|
6
|
+
* result never arrived. Typing into the terminal will not help either — the CLI
|
|
7
|
+
* is holding it open, waiting. The server shares a host with the CLI, so it
|
|
8
|
+
* makes the call on the browser's behalf.
|
|
9
|
+
*/
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { Loader2, LogIn } from "lucide-react";
|
|
12
|
+
import { api } from "@/lib/api-client";
|
|
13
|
+
import { isDeliverableCallback } from "@/lib/oauth-loopback-url";
|
|
14
|
+
import { toast } from "sonner";
|
|
15
|
+
|
|
16
|
+
interface TerminalFinishLoginProps {
|
|
17
|
+
onDone: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TerminalFinishLogin({ onDone }: TerminalFinishLoginProps) {
|
|
21
|
+
const [url, setUrl] = useState("");
|
|
22
|
+
const [sending, setSending] = useState(false);
|
|
23
|
+
|
|
24
|
+
const trimmed = url.trim();
|
|
25
|
+
const ready = !sending && isDeliverableCallback(trimmed);
|
|
26
|
+
|
|
27
|
+
const send = async () => {
|
|
28
|
+
setSending(true);
|
|
29
|
+
try {
|
|
30
|
+
await api.post("/api/loopback/callback", { url: trimmed });
|
|
31
|
+
toast.success("Sent to the waiting command", { duration: 2000 });
|
|
32
|
+
setUrl("");
|
|
33
|
+
onDone();
|
|
34
|
+
} catch (e) {
|
|
35
|
+
toast.error((e as Error).message, { duration: 4000 });
|
|
36
|
+
} finally {
|
|
37
|
+
setSending(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="shrink-0 border-t border-border px-3 py-3">
|
|
43
|
+
<p className="pb-2 text-[11px] leading-snug text-text-secondary">
|
|
44
|
+
Finished signing in and landed on a page that would not load? Paste that address here —
|
|
45
|
+
the machine will deliver it to the command still waiting.
|
|
46
|
+
</p>
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<input
|
|
49
|
+
value={url}
|
|
50
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
51
|
+
inputMode="url"
|
|
52
|
+
autoCapitalize="off"
|
|
53
|
+
autoCorrect="off"
|
|
54
|
+
spellCheck={false}
|
|
55
|
+
placeholder="http://127.0.0.1:54132/oauth/callback?code=…"
|
|
56
|
+
aria-label="Callback address"
|
|
57
|
+
className="h-11 min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 font-mono text-xs text-foreground placeholder:text-text-subtle"
|
|
58
|
+
/>
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => void send()}
|
|
61
|
+
disabled={!ready}
|
|
62
|
+
aria-label="Finish login"
|
|
63
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground disabled:opacity-40 active:opacity-80 transition-opacity"
|
|
64
|
+
>
|
|
65
|
+
{sending ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
{trimmed !== "" && !isDeliverableCallback(trimmed) && (
|
|
69
|
+
<p className="pt-1.5 text-[11px] text-error">
|
|
70
|
+
Needs to be the http://127.0.0.1:… address the sign-in redirected to.
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
* xterm resolves a link from a mouse hover and opens it on mouseup, so a touch
|
|
5
5
|
* device can never activate one — and without touch selection the URL cannot be
|
|
6
6
|
* copied out either. This sheet is the touch path to both.
|
|
7
|
+
*
|
|
8
|
+
* It also carries the way out of a login started here but finished on another
|
|
9
|
+
* device: see TerminalFinishLogin.
|
|
7
10
|
*/
|
|
8
11
|
import { ExternalLink, Copy, Link2Off } from "lucide-react";
|
|
9
12
|
import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
|
|
10
13
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
14
|
+
import { loopbackRedirectPort } from "@/lib/oauth-loopback-url";
|
|
15
|
+
import { TerminalFinishLogin } from "./terminal-finish-login";
|
|
11
16
|
import { toast } from "sonner";
|
|
12
17
|
|
|
13
18
|
interface TerminalLinksSheetProps {
|
|
@@ -23,6 +28,10 @@ export function TerminalLinksSheet({ open, onClose, urls }: TerminalLinksSheetPr
|
|
|
23
28
|
onClose();
|
|
24
29
|
};
|
|
25
30
|
|
|
31
|
+
// Ports any listed login will redirect back to. Their presence is what makes
|
|
32
|
+
// the finish-login step relevant, so it stays hidden the rest of the time.
|
|
33
|
+
const loopbackPorts = [...new Set(urls.map(loopbackRedirectPort).filter((p): p is string => !!p))];
|
|
34
|
+
|
|
26
35
|
return (
|
|
27
36
|
// Above the mobile dock sheet (z-60) this is opened from, or it renders
|
|
28
37
|
// behind the dock and cannot be reached at all.
|
|
@@ -31,36 +40,50 @@ export function TerminalLinksSheet({ open, onClose, urls }: TerminalLinksSheetPr
|
|
|
31
40
|
{urls.length > 0 ? `Links in terminal (${urls.length})` : "Links in terminal"}
|
|
32
41
|
</div>
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
43
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2">
|
|
44
|
+
{urls.length === 0 ? (
|
|
45
|
+
<div className="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-text-secondary">
|
|
46
|
+
<Link2Off className="size-5 text-text-subtle" />
|
|
47
|
+
No links found in the visible scrollback.
|
|
48
|
+
</div>
|
|
49
|
+
) : (
|
|
50
|
+
urls.map((url) => {
|
|
51
|
+
const port = loopbackRedirectPort(url);
|
|
52
|
+
return (
|
|
53
|
+
<div key={url}>
|
|
54
|
+
<div className="flex items-stretch gap-1">
|
|
55
|
+
<a
|
|
56
|
+
href={url}
|
|
57
|
+
target="_blank"
|
|
58
|
+
rel="noreferrer noopener"
|
|
59
|
+
onClick={onClose}
|
|
60
|
+
className="flex min-h-11 flex-1 min-w-0 items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground active:bg-accent transition-colors"
|
|
61
|
+
>
|
|
62
|
+
<ExternalLink className="size-4 shrink-0 text-text-subtle" />
|
|
63
|
+
<span className="min-w-0 flex-1 truncate text-left font-mono text-xs">{url}</span>
|
|
64
|
+
</a>
|
|
65
|
+
<button
|
|
66
|
+
onClick={() => void handleCopy(url)}
|
|
67
|
+
aria-label={`Copy ${url}`}
|
|
68
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-lg text-text-secondary active:bg-accent transition-colors"
|
|
69
|
+
>
|
|
70
|
+
<Copy className="size-4" />
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
{port && (
|
|
74
|
+
<p className="px-3 pb-2 text-[11px] leading-snug text-text-secondary">
|
|
75
|
+
Signing in here sends you back to port {port} <strong>on the machine</strong>,
|
|
76
|
+
not this device — so the terminal keeps waiting. Afterwards, paste the address
|
|
77
|
+
bar below to finish it.
|
|
78
|
+
</p>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
})
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{loopbackPorts.length > 0 && <TerminalFinishLogin onDone={onClose} />}
|
|
64
87
|
</BottomSheet>
|
|
65
88
|
);
|
|
66
89
|
}
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* Also exports context-menu-specific sub-components (BottomSheetItem, etc.)
|
|
7
7
|
* used by adaptive-context-menu.tsx.
|
|
8
8
|
*/
|
|
9
|
-
import { createContext, useContext,
|
|
9
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
10
10
|
import { createPortal } from "react-dom";
|
|
11
11
|
import { cn } from "@/lib/utils";
|
|
12
12
|
import { useSwipeToDismiss } from "@/hooks/use-swipe-to-dismiss";
|
|
13
|
+
import { useVisualViewport } from "@/hooks/use-visual-viewport";
|
|
13
14
|
|
|
14
15
|
/* ------------------------------------------------------------------ */
|
|
15
16
|
/* Core BottomSheet — reusable everywhere */
|
|
@@ -33,44 +34,46 @@ export function BottomSheet({ open, onClose, children, className, zIndex = 50 }:
|
|
|
33
34
|
const { dragY, swipeHandlers, dragStyle, backdropOpacity, isDragging } =
|
|
34
35
|
useSwipeToDismiss(onClose);
|
|
35
36
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
const viewport = window.visualViewport;
|
|
43
|
-
if (!open || !viewport) return;
|
|
44
|
-
const update = () => setVv({ top: viewport.offsetTop, height: viewport.height });
|
|
45
|
-
update();
|
|
46
|
-
viewport.addEventListener("resize", update);
|
|
47
|
-
viewport.addEventListener("scroll", update);
|
|
48
|
-
return () => {
|
|
49
|
-
viewport.removeEventListener("resize", update);
|
|
50
|
-
viewport.removeEventListener("scroll", update);
|
|
51
|
-
};
|
|
52
|
-
}, [open]);
|
|
37
|
+
// Keyboard-aware geometry. The backdrop deliberately keeps covering the whole
|
|
38
|
+
// layout viewport while only the panel tracks the keyboard: if the two ever
|
|
39
|
+
// disagree by a few pixels, the seam shows as dimmed backdrop rather than a
|
|
40
|
+
// strip of untouched page.
|
|
41
|
+
const insets = useVisualViewport(open);
|
|
53
42
|
|
|
54
43
|
if (!open) return null;
|
|
55
44
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
45
|
+
// The panel keeps its footing on the bottom of the screen and only its
|
|
46
|
+
// *contents* are lifted clear of the keyboard, by padding. Moving the panel
|
|
47
|
+
// itself is what left a hole: the keyboard's height can only be estimated, and
|
|
48
|
+
// an estimate that is short drags the panel's background up with it, exposing
|
|
49
|
+
// the page. Padding an anchored panel degrades instead — a bad estimate puts
|
|
50
|
+
// the toolbar slightly off, over a background that still reaches the bottom.
|
|
51
|
+
//
|
|
52
|
+
// `--sheet-vh` is what is visible and `--sheet-kb` what the keyboard covers,
|
|
53
|
+
// so a panel can ask for "the visible area" and still span to the screen edge.
|
|
54
|
+
const frameStyle = {
|
|
55
|
+
zIndex,
|
|
56
|
+
"--sheet-vh": insets ? `${insets.height}px` : "100dvh",
|
|
57
|
+
"--sheet-kb": insets ? `${insets.keyboardInset}px` : "0px",
|
|
58
|
+
} as React.CSSProperties;
|
|
61
59
|
|
|
62
60
|
return createPortal(
|
|
63
|
-
<div className="fixed inset-0" style={
|
|
61
|
+
<div className="fixed inset-0" style={frameStyle} onClick={onClose}>
|
|
64
62
|
{/* Backdrop */}
|
|
65
63
|
<div
|
|
66
64
|
className="absolute inset-0 bg-black/40 animate-in fade-in-0 duration-200"
|
|
67
65
|
style={isDragging ? { opacity: backdropOpacity } : undefined}
|
|
68
66
|
/>
|
|
69
|
-
{/* Panel
|
|
67
|
+
{/* Panel. Padding does double duty: it clears the home indicator when the
|
|
68
|
+
keyboard is down, and the keyboard itself when it is up. A panel sized
|
|
69
|
+
in `--sheet-vh` must add `--sheet-kb` back, since the padding is inside
|
|
70
|
+
that height. */}
|
|
70
71
|
<div
|
|
71
72
|
className={cn(
|
|
72
73
|
"absolute bottom-0 left-0 right-0 rounded-t-2xl bg-popover text-popover-foreground border-t border-border",
|
|
73
|
-
|
|
74
|
+
insets?.keyboardOpen
|
|
75
|
+
? "pb-[var(--sheet-kb,0px)]"
|
|
76
|
+
: "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
74
77
|
!isDragging && "animate-in slide-in-from-bottom duration-200",
|
|
75
78
|
className,
|
|
76
79
|
)}
|
|
@@ -829,18 +829,19 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
829
829
|
|
|
830
830
|
if (sessionId && projectName) {
|
|
831
831
|
setMessagesLoading(true);
|
|
832
|
-
fetch
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
.
|
|
832
|
+
// Via api.get, not raw fetch: the loading screen is gated on this settling,
|
|
833
|
+
// and a stalled request would otherwise hold the transcript hostage until
|
|
834
|
+
// a page reload.
|
|
835
|
+
api
|
|
836
|
+
.get<any>(
|
|
837
|
+
`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`,
|
|
838
|
+
)
|
|
839
|
+
.then((data: any) => {
|
|
837
840
|
if (cancelled) return;
|
|
838
841
|
// Tolerate the pre-versionMap shape (a bare array): a browser running a
|
|
839
842
|
// cached bundle from before the upgrade would otherwise render an empty
|
|
840
843
|
// history with no error.
|
|
841
|
-
const payload =
|
|
842
|
-
? (Array.isArray(json.data) ? { messages: json.data, versionMap: {} } : json.data)
|
|
843
|
-
: null;
|
|
844
|
+
const payload = Array.isArray(data) ? { messages: data, versionMap: {} } : data;
|
|
844
845
|
let history: ChatMessage[] = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
845
846
|
if (payload?.versionMap) setVersionMap(payload.versionMap);
|
|
846
847
|
// If a live turn_events replay already owns the active (unfinished) turn,
|
|
@@ -44,6 +44,8 @@ const URL_TRAILING_PUNCT = /[).,;:'"\]}>]+$/;
|
|
|
44
44
|
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/g;
|
|
45
45
|
/** Cap the scan so a 50k-line scrollback never blocks the tap that triggered it. */
|
|
46
46
|
const URL_SCAN_MAX_ROWS = 5000;
|
|
47
|
+
/** Quiet period before refitting to a new container size, in ms. */
|
|
48
|
+
const RESIZE_SETTLE_MS = 100;
|
|
47
49
|
|
|
48
50
|
const RESIZE_PREFIX = "\x01RESIZE:";
|
|
49
51
|
const PING_MSG = "\x01PING";
|
|
@@ -363,16 +365,24 @@ export function useTerminal(
|
|
|
363
365
|
};
|
|
364
366
|
document.addEventListener("visibilitychange", onVisibility);
|
|
365
367
|
|
|
366
|
-
// ResizeObserver for auto-fit — skip when tab is hidden (0 dimensions)
|
|
368
|
+
// ResizeObserver for auto-fit — skip when tab is hidden (0 dimensions).
|
|
369
|
+
// Debounced: an on-screen keyboard resizes the container many times as it
|
|
370
|
+
// slides, and refitting on each one sends the shell a burst of size changes
|
|
371
|
+
// that it answers by redrawing, so the output visibly churns. One fit once
|
|
372
|
+
// the size settles gives the same result without the churn.
|
|
373
|
+
let fitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
367
374
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
368
375
|
const entry = entries[0];
|
|
369
376
|
if (!entry || entry.contentRect.width === 0 || entry.contentRect.height === 0) return;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
377
|
+
if (fitTimer) clearTimeout(fitTimer);
|
|
378
|
+
fitTimer = setTimeout(() => {
|
|
379
|
+
try {
|
|
380
|
+
fitAddon.fit();
|
|
381
|
+
sendResize();
|
|
382
|
+
} catch {
|
|
383
|
+
// Ignore fit errors during teardown
|
|
384
|
+
}
|
|
385
|
+
}, RESIZE_SETTLE_MS);
|
|
376
386
|
});
|
|
377
387
|
resizeObserver.observe(container);
|
|
378
388
|
|
|
@@ -384,6 +394,7 @@ export function useTerminal(
|
|
|
384
394
|
|
|
385
395
|
return () => {
|
|
386
396
|
unsubTheme();
|
|
397
|
+
if (fitTimer) clearTimeout(fitTimer);
|
|
387
398
|
resizeObserver.disconnect();
|
|
388
399
|
clearInterval(heartbeatInterval);
|
|
389
400
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
3
3
|
import type { UsageInfo } from "../../types/chat";
|
|
4
4
|
|
|
5
5
|
const POLL_INTERVAL = 120_000; // read cache every 2min
|
|
@@ -22,15 +22,16 @@ export function useUsage(projectName: string, providerId = "claude"): UseUsageRe
|
|
|
22
22
|
if (!projectName) return;
|
|
23
23
|
setUsageLoading(true);
|
|
24
24
|
const qs = forceRefresh ? "&refresh=1" : "";
|
|
25
|
-
fetch
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
25
|
+
// Via api.get, not raw fetch: the toolbar's loading state is gated on this
|
|
26
|
+
// settling, and a raw fetch has no timeout to stop it stalling forever.
|
|
27
|
+
api
|
|
28
|
+
.get<(UsageInfo & { lastFetchedAt?: string }) | null>(
|
|
29
|
+
`${projectUrl(projectName)}/chat/usage?providerId=${providerId}${qs}`,
|
|
30
|
+
)
|
|
31
|
+
.then((data) => {
|
|
32
|
+
if (!data) return;
|
|
33
|
+
setUsageInfo((prev) => ({ ...prev, ...data }));
|
|
34
|
+
if (data.lastFetchedAt) setLastFetchedAt(data.lastFetchedAt);
|
|
34
35
|
})
|
|
35
36
|
.catch(() => {})
|
|
36
37
|
.finally(() => setUsageLoading(false));
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measures the part of the screen the user can actually see, and how much of it
|
|
3
|
+
* the on-screen keyboard is occupying.
|
|
4
|
+
*
|
|
5
|
+
* `vh`/`dvh` units and a `fixed` element both resolve against the layout
|
|
6
|
+
* viewport, which does not shrink when a keyboard opens — so anything anchored
|
|
7
|
+
* to the bottom ends up behind it. Only `visualViewport` reports the truth.
|
|
8
|
+
*
|
|
9
|
+
* Updates are applied on the frame they arrive rather than after a settle
|
|
10
|
+
* delay: iOS emits intermediate sizes throughout the keyboard's slide and its
|
|
11
|
+
* final event can land before the layout has finished moving, so deferring is
|
|
12
|
+
* what leaves geometry stale — visibly, as a strip of untouched page below a
|
|
13
|
+
* panel that should be flush with the keyboard.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
|
|
17
|
+
export interface ViewportInsets {
|
|
18
|
+
/** Height of the visible area, in px. */
|
|
19
|
+
height: number;
|
|
20
|
+
/**
|
|
21
|
+
* Roughly how much the keyboard covers, in px.
|
|
22
|
+
*
|
|
23
|
+
* Approximate on purpose: `innerHeight` on iOS counts space behind browser
|
|
24
|
+
* chrome, so this can be off by a bar's height. Use it to inset content, never
|
|
25
|
+
* to place a background — an inset that is slightly wrong shifts content a
|
|
26
|
+
* little, whereas a background placed the same way leaves a visible hole.
|
|
27
|
+
*/
|
|
28
|
+
keyboardInset: number;
|
|
29
|
+
/** Whether the missing height is big enough to be a keyboard, not browser chrome. */
|
|
30
|
+
keyboardOpen: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Below this, the missing height is a collapsed address bar, not a keyboard. */
|
|
34
|
+
const KEYBOARD_MIN_INSET = 120;
|
|
35
|
+
|
|
36
|
+
function measure(viewport: VisualViewport): ViewportInsets {
|
|
37
|
+
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
|
38
|
+
return {
|
|
39
|
+
height: viewport.height,
|
|
40
|
+
keyboardInset: inset,
|
|
41
|
+
keyboardOpen: inset >= KEYBOARD_MIN_INSET,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Live viewport insets, or null when unsupported or while `active` is false. */
|
|
46
|
+
export function useVisualViewport(active: boolean): ViewportInsets | null {
|
|
47
|
+
const [insets, setInsets] = useState<ViewportInsets | null>(null);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const viewport = window.visualViewport;
|
|
51
|
+
if (!active || !viewport) {
|
|
52
|
+
setInsets(null);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Several resize/scroll events can fire per frame; one read per frame is
|
|
57
|
+
// enough and keeps React from rendering the same geometry repeatedly.
|
|
58
|
+
let frame: number | null = null;
|
|
59
|
+
const update = () => {
|
|
60
|
+
frame = null;
|
|
61
|
+
setInsets((prev) => {
|
|
62
|
+
const next = measure(viewport);
|
|
63
|
+
return prev && prev.height === next.height && prev.keyboardInset === next.keyboardInset
|
|
64
|
+
? prev
|
|
65
|
+
: next;
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const schedule = () => {
|
|
69
|
+
if (frame === null) frame = requestAnimationFrame(update);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
update();
|
|
73
|
+
viewport.addEventListener("resize", schedule);
|
|
74
|
+
viewport.addEventListener("scroll", schedule);
|
|
75
|
+
return () => {
|
|
76
|
+
if (frame !== null) cancelAnimationFrame(frame);
|
|
77
|
+
viewport.removeEventListener("resize", schedule);
|
|
78
|
+
viewport.removeEventListener("scroll", schedule);
|
|
79
|
+
};
|
|
80
|
+
}, [active]);
|
|
81
|
+
|
|
82
|
+
return insets;
|
|
83
|
+
}
|
|
@@ -14,7 +14,7 @@ function warnOnAuditFailure(res: Response): void {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/** GETs currently awaiting a response, keyed by absolute URL. See ApiClient.get. */
|
|
17
|
-
const pendingGets = new Map<string, Promise<unknown
|
|
17
|
+
const pendingGets = new Map<string, { promise: Promise<unknown>; startedAt: number }>();
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* How long a GET may wait for response headers. Browsers do not time a stalled
|
|
@@ -64,6 +64,11 @@ export class ApiClient {
|
|
|
64
64
|
* other on a single tab open. This is in-flight sharing only, NOT a response
|
|
65
65
|
* cache: the entry is dropped as soon as it settles, so nothing goes stale.
|
|
66
66
|
*
|
|
67
|
+
* Sharing is also capped in time. The response timer stops once headers
|
|
68
|
+
* arrive, so a request that stalls midway through its body never settles —
|
|
69
|
+
* and an unsettled entry must not hold back every later caller of that URL.
|
|
70
|
+
* Past that window a new caller starts its own request instead.
|
|
71
|
+
*
|
|
67
72
|
* Requests carrying an AbortSignal opt out — one caller aborting must not cancel
|
|
68
73
|
* another's request.
|
|
69
74
|
*/
|
|
@@ -71,12 +76,18 @@ export class ApiClient {
|
|
|
71
76
|
if (options?.signal) return this.rawGet<T>(path, options.signal);
|
|
72
77
|
|
|
73
78
|
const key = `${this.baseUrl}${path}`;
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
79
|
+
const entry = pendingGets.get(key);
|
|
80
|
+
if (entry && Date.now() - entry.startedAt < this.responseTimeoutMs) {
|
|
81
|
+
return entry.promise as Promise<T>;
|
|
82
|
+
}
|
|
76
83
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
const promise = this.rawGet<T>(path).finally(() => {
|
|
85
|
+
// Drop only our own entry — an abandoned request settling late must not
|
|
86
|
+
// evict the newer one that replaced it.
|
|
87
|
+
if (pendingGets.get(key)?.promise === promise) pendingGets.delete(key);
|
|
88
|
+
});
|
|
89
|
+
pendingGets.set(key, { promise, startedAt: Date.now() });
|
|
90
|
+
return promise;
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
private async rawGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognises OAuth authorization URLs that hand their result back to a port on
|
|
3
|
+
* the machine running the CLI, rather than to whatever device opened them.
|
|
4
|
+
*
|
|
5
|
+
* This is what strands a login started from a phone: the sign-in succeeds, the
|
|
6
|
+
* browser is redirected to 127.0.0.1:<port>, and on the phone that address is
|
|
7
|
+
* the phone itself — so the CLI waiting on the real machine never hears back.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1"]);
|
|
11
|
+
|
|
12
|
+
/** Whether a hostname addresses the machine the browser is running on. */
|
|
13
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
14
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
15
|
+
if (LOOPBACK_HOSTNAMES.has(host)) return true;
|
|
16
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The loopback port an authorization URL will redirect back to, or null when it
|
|
21
|
+
* redirects somewhere reachable from any device.
|
|
22
|
+
*/
|
|
23
|
+
export function loopbackRedirectPort(url: string): string | null {
|
|
24
|
+
let parsed: URL;
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(url);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// `redirect_uri` is the OAuth 2.0 name; some tools still send `redirectUri`.
|
|
31
|
+
const redirect =
|
|
32
|
+
parsed.searchParams.get("redirect_uri") ?? parsed.searchParams.get("redirectUri");
|
|
33
|
+
if (!redirect) return null;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const target = new URL(redirect);
|
|
37
|
+
if (!isLoopbackHostname(target.hostname)) return null;
|
|
38
|
+
return target.port || (target.protocol === "https:" ? "443" : "80");
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether a pasted string is a callback this machine could deliver. */
|
|
45
|
+
export function isDeliverableCallback(url: string): boolean {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(url.trim());
|
|
48
|
+
return (
|
|
49
|
+
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
50
|
+
isLoopbackHostname(parsed.hostname)
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import"./vendor-markdown-CKTlmXbz.js";import"./vendor-ui-DMRGWRuc.js";import{i as e,n as t}from"./api-client-BgM59GxL.js";export{t as api,e as projectUrl};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import"./vendor-markdown-CKTlmXbz.js";import"./vendor-ui-DMRGWRuc.js";import"./api-client-BgM59GxL.js";import{v as e}from"./api-settings-D108koFG.js";export{e as updateDeviceName};
|