@hienlh/ppm 0.17.34 → 0.17.37
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 +17 -0
- package/CLAUDE.md +1 -0
- package/Dockerfile.test +5 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +5 -0
- package/dist/web/assets/{ai-resource-editor-QNfSU0Fi.js → ai-resource-editor-DAIaB4mX.js} +1 -1
- package/dist/web/assets/{api-client-DhXpjLdi.js → api-client-BgM59GxL.js} +1 -1
- package/dist/web/assets/api-client-BwMdPtp6.js +1 -0
- package/dist/web/assets/{api-settings-BUYvGBdi.js → api-settings-D108koFG.js} +1 -1
- package/dist/web/assets/api-settings-D2fY0GUc.js +1 -0
- package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-cPGCYGB_.js} +1 -1
- package/dist/web/assets/{audio-preview-IokB77Cj.js → audio-preview-Df6_blMs.js} +1 -1
- package/dist/web/assets/chat-tab-yPXWmX01.js +13 -0
- package/dist/web/assets/{code-editor-B8YiBPOX.js → code-editor-CSDB2W8N.js} +2 -2
- package/dist/web/assets/{conflict-editor-CY1o5kBO.js → conflict-editor-BqMdj782.js} +1 -1
- package/dist/web/assets/{csv-preview-AEg0GB-V.js → csv-preview-BC_BuOzK.js} +1 -1
- package/dist/web/assets/{database-viewer-C5Meg0s_.js → database-viewer-DZQDQOye.js} +1 -1
- package/dist/web/assets/diff-viewer-CaKD5pR5.js +4 -0
- package/dist/web/assets/docx-preview-MBY4op8y.js +12 -0
- package/dist/web/assets/{edit-diff-preview-_CXUdwsF.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
- package/dist/web/assets/{extension-webview-CQceYb7O.js → extension-webview-CMtucoeg.js} +1 -1
- package/dist/web/assets/git-log-panel-DWZUIupS.js +1 -0
- package/dist/web/assets/{glide-data-grid-cGxkgluR.js → glide-data-grid-SLs8Xp6G.js} +1 -1
- package/dist/web/assets/{group-chat-tab-B-eA5ytm.js → group-chat-tab-Celp6ihW.js} +1 -1
- package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
- package/dist/web/assets/{image-preview-DRUzYptI.js → image-preview-lRcpEoEk.js} +1 -1
- package/dist/web/assets/index-BkH2b_6W.js +50 -0
- package/dist/web/assets/index-eW0VEaRF.css +2 -0
- package/dist/web/assets/keybindings-store-CtfEQL3U.js +1 -0
- package/dist/web/assets/markdown-renderer-D9r_4-qe.js +3 -0
- package/dist/web/assets/markdown-renderer-DkYo6o9f.js +1 -0
- package/dist/web/assets/notification-store-OF6L8lWu.js +1 -0
- package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
- package/dist/web/assets/pdf-preview-D17y9l8U.js +1 -0
- package/dist/web/assets/{postgres-viewer-BeantJZe.js → postgres-viewer-DgpX_Kyl.js} +1 -1
- package/dist/web/assets/project-store-8_AdCduJ.js +1 -0
- package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-BhZ7VYJD.js} +2 -2
- package/dist/web/assets/settings-tab-Cou9P1gO.js +1 -0
- package/dist/web/assets/{sql-query-editor-DqU9zfVy.js → sql-query-editor-BIeAQego.js} +1 -1
- package/dist/web/assets/sqlite-viewer-SipxlqWM.js +1 -0
- package/dist/web/assets/{system-monitor-tab-Bue4p4Vb.js → system-monitor-tab-Cp-rP5HZ.js} +1 -1
- package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
- package/dist/web/assets/{terminal-tab-BfNnKU71.js → terminal-tab-C9T_X4oo.js} +1 -1
- package/dist/web/assets/tool-cards-BFVmoF_j.js +7 -0
- package/dist/web/assets/use-blob-url-BYDGMRMU.js +1 -0
- package/dist/web/assets/{use-monaco-theme-5XjAzOzE.js → use-monaco-theme-Bd1GBgB3.js} +1 -1
- package/dist/web/assets/{video-preview-DMqLOqmc.js → video-preview-DT8B2-re.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/sw.js +1 -1
- package/docker-compose.test.yml +5 -1
- package/package.json +4 -3
- package/src/server/routes/chat.ts +66 -1
- package/src/services/image-dimensions.ts +67 -0
- package/src/services/transcript-images.ts +174 -0
- package/src/shared/tool-result-content.ts +14 -3
- package/src/web/components/chat/chat-history-bar.tsx +3 -104
- package/src/web/components/chat/message-input.tsx +4 -2
- package/src/web/components/chat/message-list.tsx +10 -3
- package/src/web/components/chat/session-debug-dialog.tsx +240 -0
- package/src/web/components/chat/tool-image-preview.tsx +4 -2
- package/src/web/components/group-chat/group-create-dialog.tsx +2 -1
- package/src/web/components/layout/editor-panel.tsx +31 -5
- package/src/web/components/layout/notification-bell-popover.tsx +5 -1
- package/src/web/components/layout/tab-bar.tsx +2 -2
- package/src/web/components/shared/image-overlay.tsx +188 -24
- package/src/web/components/shared/markdown-context.ts +1 -1
- package/src/web/components/shared/markdown-renderer.tsx +3 -1
- package/src/web/hooks/use-draft.ts +17 -2
- package/src/web/hooks/use-image-transform.ts +276 -0
- package/src/web/lib/api-client.ts +50 -7
- package/src/web/lib/clipboard.ts +41 -0
- package/src/web/lib/file-download.ts +1 -1
- package/src/web/lib/image-gallery.ts +25 -0
- package/src/web/stores/image-overlay-store.ts +35 -7
- package/src/web/stores/panel-store.ts +40 -6
- package/src/web/stores/panel-utils.ts +19 -0
- package/dist/web/assets/api-client-DNTkryX1.js +0 -1
- package/dist/web/assets/api-settings-Bp5HXQCW.js +0 -1
- package/dist/web/assets/chat-tab-BZNEvS88.js +0 -13
- package/dist/web/assets/diff-viewer-BRRGvZnp.js +0 -4
- package/dist/web/assets/docx-preview-DqTX7NDp.js +0 -12
- package/dist/web/assets/git-log-panel-D7QlAJqu.js +0 -1
- package/dist/web/assets/index-BuX7JkCt.js +0 -50
- package/dist/web/assets/index-PX2pZ3bH.css +0 -2
- package/dist/web/assets/keybindings-store-B3plqyxF.js +0 -1
- package/dist/web/assets/markdown-renderer-BKDbjc3g.js +0 -3
- package/dist/web/assets/markdown-renderer-C9Gy8tM2.js +0 -1
- package/dist/web/assets/notification-store-jFDauzse.js +0 -1
- package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
- package/dist/web/assets/pdf-preview-CzHUqXJB.js +0 -1
- package/dist/web/assets/project-store-C1jInVih.js +0 -1
- package/dist/web/assets/settings-tab-CqQHhqV1.js +0 -1
- package/dist/web/assets/sqlite-viewer-BH891DC0.js +0 -1
- package/dist/web/assets/tool-cards-CSOwe7_i.js +0 -7
- package/dist/web/assets/use-blob-url-jSRmyXVg.js +0 -1
- package/skills-lock.json +0 -11
- package/spike-memory-region-dump.ps1 +0 -69
- /package/dist/web/assets/{csv-parser-BPS94T2h.js → csv-parser-DVHBcvuE.js} +0 -0
- /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
- /package/dist/web/assets/{libesm-BS7D9de3.js → libesm-DdtEjhIi.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
|
@@ -1,45 +1,209 @@
|
|
|
1
|
-
import { useEffect,
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Check, ChevronLeft, ChevronRight, Copy, Download, FlipHorizontal, FlipVertical,
|
|
4
|
+
Maximize2, Minus, Plus, RotateCcw, RotateCw, Scan, X,
|
|
5
|
+
} from "lucide-react";
|
|
3
6
|
import { useImageOverlay } from "@/stores/image-overlay-store";
|
|
7
|
+
import { useImageTransform } from "@/hooks/use-image-transform";
|
|
8
|
+
import { canCopyImage, copyImageToClipboard } from "@/lib/clipboard";
|
|
9
|
+
import { triggerDownload } from "@/lib/file-download";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
4
11
|
|
|
5
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Global image lightbox with zoom, pan, rotate and flip — mount once in app root.
|
|
14
|
+
*
|
|
15
|
+
* The viewer is a separate component so its gesture bindings attach on the same commit that
|
|
16
|
+
* puts the container in the DOM. Running them from here would bind against a ref that is
|
|
17
|
+
* still null while the overlay is closed, and they would never attach at all.
|
|
18
|
+
*/
|
|
6
19
|
export function ImageOverlay() {
|
|
7
|
-
const
|
|
20
|
+
const src = useImageOverlay((s) => s.src);
|
|
21
|
+
if (!src) return null;
|
|
22
|
+
return <Viewer key={src} />;
|
|
23
|
+
}
|
|
8
24
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
);
|
|
25
|
+
function Viewer() {
|
|
26
|
+
const { src, alt, images, index, close, go } = useImageOverlay();
|
|
27
|
+
const {
|
|
28
|
+
containerRef, imageRef, zoomPercent, rotation,
|
|
29
|
+
zoomBy, reset, fit, actualSize, rotate, flip,
|
|
30
|
+
} = useImageTransform();
|
|
31
|
+
|
|
32
|
+
const [copied, setCopied] = useState(false);
|
|
33
|
+
const copyable = canCopyImage();
|
|
34
|
+
|
|
35
|
+
// Give the image an explicit identity transform up front, so the first gesture animates
|
|
36
|
+
// from a known state rather than from an empty style. Remounting per image (see the `key`
|
|
37
|
+
// above) is what keeps a 400% zoom from carrying over to the next one.
|
|
38
|
+
useEffect(() => { reset(); }, [reset]);
|
|
39
|
+
|
|
40
|
+
const actions = useRef({ close, go, zoomBy, reset, fit, actualSize, rotate, flip });
|
|
41
|
+
actions.current = { close, go, zoomBy, reset, fit, actualSize, rotate, flip };
|
|
15
42
|
|
|
16
43
|
useEffect(() => {
|
|
17
44
|
if (!src) return;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
45
|
+
const onKey = (e: KeyboardEvent) => {
|
|
46
|
+
const a = actions.current;
|
|
47
|
+
const key = e.key;
|
|
48
|
+
if (key === "Escape") return a.close();
|
|
49
|
+
if (key === "ArrowLeft") return a.go(-1);
|
|
50
|
+
if (key === "ArrowRight") return a.go(1);
|
|
51
|
+
if (key === "+" || key === "=") return a.zoomBy(1.25);
|
|
52
|
+
if (key === "-" || key === "_") return a.zoomBy(0.8);
|
|
53
|
+
if (key === "0") return a.fit();
|
|
54
|
+
if (key === "1") return a.actualSize();
|
|
55
|
+
if (key === "r") return a.rotate(90);
|
|
56
|
+
if (key === "R") return a.rotate(-90);
|
|
57
|
+
if (key === "h") return a.flip("x");
|
|
58
|
+
if (key === "v") return a.flip("y");
|
|
59
|
+
};
|
|
60
|
+
document.addEventListener("keydown", onKey);
|
|
61
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
62
|
+
}, [src]);
|
|
21
63
|
|
|
22
64
|
if (!src) return null;
|
|
23
65
|
|
|
66
|
+
const name = alt?.trim() || "image";
|
|
67
|
+
const many = images.length > 1;
|
|
68
|
+
|
|
69
|
+
const copy = async () => {
|
|
70
|
+
if (await copyImageToClipboard(src)) {
|
|
71
|
+
setCopied(true);
|
|
72
|
+
setTimeout(() => setCopied(false), 1500);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
24
76
|
return (
|
|
25
|
-
<div
|
|
26
|
-
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-150"
|
|
27
|
-
onClick={close}
|
|
28
|
-
>
|
|
77
|
+
<div className="fixed inset-0 z-[100] flex flex-col bg-black/90 backdrop-blur-sm animate-in fade-in duration-150">
|
|
29
78
|
<button
|
|
30
79
|
onClick={close}
|
|
31
|
-
className="absolute top-
|
|
80
|
+
className="absolute top-3 right-3 z-20 flex size-11 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 active:bg-white/30"
|
|
32
81
|
aria-label="Close"
|
|
33
82
|
>
|
|
34
83
|
<X className="size-5" />
|
|
35
84
|
</button>
|
|
36
85
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
86
|
+
{many && (
|
|
87
|
+
<div className="absolute top-3 left-3 z-20 rounded-full bg-white/10 px-3 py-1.5 font-mono text-xs text-white/80">
|
|
88
|
+
{index + 1} / {images.length}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/*
|
|
93
|
+
touch-none hands every finger to the gesture handler; without it the browser claims
|
|
94
|
+
the drag for its own scroll and pinch, and neither pan nor zoom ever fires.
|
|
95
|
+
*/}
|
|
96
|
+
<div
|
|
97
|
+
ref={containerRef}
|
|
98
|
+
className="relative flex flex-1 touch-none select-none items-center justify-center overflow-hidden"
|
|
99
|
+
onPointerDown={(e) => { if (e.target === e.currentTarget) e.currentTarget.dataset.bg = "1"; }}
|
|
100
|
+
onPointerUp={(e) => {
|
|
101
|
+
// Close only on a click that started and ended on the backdrop, so releasing a
|
|
102
|
+
// pan outside the image does not dismiss the viewer.
|
|
103
|
+
if (e.target === e.currentTarget && e.currentTarget.dataset.bg === "1") close();
|
|
104
|
+
delete e.currentTarget.dataset.bg;
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<img
|
|
108
|
+
ref={imageRef}
|
|
109
|
+
src={src}
|
|
110
|
+
alt={alt}
|
|
111
|
+
draggable={false}
|
|
112
|
+
style={{ willChange: "transform" }}
|
|
113
|
+
className="max-h-full max-w-full object-contain rounded-lg shadow-2xl"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<Toolbar>
|
|
118
|
+
{many && (
|
|
119
|
+
<>
|
|
120
|
+
<ToolButton onClick={() => go(-1)} disabled={index === 0} label="Previous image (←)">
|
|
121
|
+
<ChevronLeft className="size-5" />
|
|
122
|
+
</ToolButton>
|
|
123
|
+
<ToolButton onClick={() => go(1)} disabled={index === images.length - 1} label="Next image (→)">
|
|
124
|
+
<ChevronRight className="size-5" />
|
|
125
|
+
</ToolButton>
|
|
126
|
+
<Divider />
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<ToolButton onClick={() => zoomBy(0.8)} label="Zoom out (-)"><Minus className="size-5" /></ToolButton>
|
|
131
|
+
<button
|
|
132
|
+
onClick={reset}
|
|
133
|
+
className="h-11 min-w-[4.5rem] rounded-lg px-2 font-mono text-xs text-white/80 transition-colors hover:bg-white/10 active:bg-white/20"
|
|
134
|
+
title="Reset view"
|
|
135
|
+
>
|
|
136
|
+
{zoomPercent}%
|
|
137
|
+
</button>
|
|
138
|
+
<ToolButton onClick={() => zoomBy(1.25)} label="Zoom in (+)"><Plus className="size-5" /></ToolButton>
|
|
139
|
+
<ToolButton onClick={fit} label="Fit to screen (0)"><Maximize2 className="size-5" /></ToolButton>
|
|
140
|
+
<ToolButton onClick={actualSize} label="Actual size (1)"><Scan className="size-5" /></ToolButton>
|
|
141
|
+
|
|
142
|
+
<Divider />
|
|
143
|
+
|
|
144
|
+
<ToolButton onClick={() => rotate(-90)} label="Rotate left (Shift+R)" active={rotation !== 0}>
|
|
145
|
+
<RotateCcw className="size-5" />
|
|
146
|
+
</ToolButton>
|
|
147
|
+
<ToolButton onClick={() => rotate(90)} label="Rotate right (R)" active={rotation !== 0}>
|
|
148
|
+
<RotateCw className="size-5" />
|
|
149
|
+
</ToolButton>
|
|
150
|
+
<ToolButton onClick={() => flip("x")} label="Flip horizontally (H)"><FlipHorizontal className="size-5" /></ToolButton>
|
|
151
|
+
<ToolButton onClick={() => flip("y")} label="Flip vertically (V)"><FlipVertical className="size-5" /></ToolButton>
|
|
152
|
+
|
|
153
|
+
<Divider />
|
|
154
|
+
|
|
155
|
+
<ToolButton onClick={() => triggerDownload(src, name)} label="Download"><Download className="size-5" /></ToolButton>
|
|
156
|
+
<ToolButton
|
|
157
|
+
onClick={copy}
|
|
158
|
+
disabled={!copyable}
|
|
159
|
+
label={copyable ? "Copy image" : "Copying images needs an HTTPS or localhost address"}
|
|
160
|
+
>
|
|
161
|
+
{copied ? <Check className="size-5 text-green-400" /> : <Copy className="size-5" />}
|
|
162
|
+
</ToolButton>
|
|
163
|
+
</Toolbar>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bottom placement keeps the controls in thumb reach on a phone (design-guidelines §9). */
|
|
169
|
+
function Toolbar({ children }: { children: React.ReactNode }) {
|
|
170
|
+
return (
|
|
171
|
+
<div className="flex shrink-0 justify-center px-2 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2">
|
|
172
|
+
<div className="flex flex-wrap items-center justify-center gap-0.5 rounded-2xl bg-white/10 p-1 backdrop-blur">
|
|
173
|
+
{children}
|
|
174
|
+
</div>
|
|
43
175
|
</div>
|
|
44
176
|
);
|
|
45
177
|
}
|
|
178
|
+
|
|
179
|
+
function Divider() {
|
|
180
|
+
return <span className="mx-1 h-6 w-px bg-white/20" aria-hidden />;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ToolButton({
|
|
184
|
+
onClick, label, children, disabled, active,
|
|
185
|
+
}: {
|
|
186
|
+
onClick: () => void;
|
|
187
|
+
label: string;
|
|
188
|
+
children: React.ReactNode;
|
|
189
|
+
disabled?: boolean;
|
|
190
|
+
active?: boolean;
|
|
191
|
+
}) {
|
|
192
|
+
return (
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={onClick}
|
|
196
|
+
disabled={disabled}
|
|
197
|
+
title={label}
|
|
198
|
+
aria-label={label}
|
|
199
|
+
className={cn(
|
|
200
|
+
"flex size-11 items-center justify-center rounded-lg text-white transition-colors",
|
|
201
|
+
"hover:bg-white/15 active:bg-white/25",
|
|
202
|
+
active && "text-primary",
|
|
203
|
+
disabled && "pointer-events-none opacity-30",
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
{children}
|
|
207
|
+
</button>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -14,7 +14,7 @@ export interface MdContextValue {
|
|
|
14
14
|
/** True while the message is still streaming — defer async Shiki highlight until done. */
|
|
15
15
|
isStreaming: boolean;
|
|
16
16
|
openFileOrSearch: (path: string, line?: { start: number; end?: number }) => void;
|
|
17
|
-
openImageOverlay: (url: string, alt: string) => void;
|
|
17
|
+
openImageOverlay: (url: string, alt: string, gallery?: { src: string; alt: string }[]) => void;
|
|
18
18
|
openDiagramOverlay: (svg: string) => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -8,6 +8,7 @@ import rehypeKatex from "rehype-katex";
|
|
|
8
8
|
import { useTabStore } from "@/stores/tab-store";
|
|
9
9
|
import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
10
10
|
import { useImageOverlay } from "@/stores/image-overlay-store";
|
|
11
|
+
import { collectGallery, GALLERY_ITEM_ATTR } from "@/lib/image-gallery";
|
|
11
12
|
import { useDiagramOverlay } from "@/stores/diagram-overlay-store";
|
|
12
13
|
import { openCommandPalette } from "@/hooks/use-global-keybindings";
|
|
13
14
|
import { useBackgroundOutputStore } from "@/stores/background-output-store";
|
|
@@ -162,7 +163,8 @@ function MdImage({ src, alt, node, ...props }: any) {
|
|
|
162
163
|
<img
|
|
163
164
|
src={displaySrc}
|
|
164
165
|
alt={name}
|
|
165
|
-
|
|
166
|
+
{...{ [GALLERY_ITEM_ATTR]: "" }}
|
|
167
|
+
onClick={(e) => displaySrc && openImageOverlay(displaySrc, name, collectGallery(e.currentTarget))}
|
|
166
168
|
className="max-h-[400px] max-w-full object-contain rounded-md border border-border cursor-pointer"
|
|
167
169
|
style={{ opacity: loading ? 0.3 : 1, minHeight: loading ? 48 : undefined, minWidth: loading ? 48 : undefined }}
|
|
168
170
|
{...props}
|
|
@@ -11,6 +11,13 @@ interface DraftState {
|
|
|
11
11
|
attachments: DraftAttachment[];
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* How long the chat composer may stay hidden waiting for a draft. The composer
|
|
16
|
+
* is gated on `draftLoading`, so a slow or stalled load must not leave the user
|
|
17
|
+
* with no way to type.
|
|
18
|
+
*/
|
|
19
|
+
const GATE_RELEASE_MS = 3_000;
|
|
20
|
+
|
|
14
21
|
interface DraftResult {
|
|
15
22
|
content: string;
|
|
16
23
|
attachments: string; // JSON string
|
|
@@ -34,6 +41,11 @@ export function useDraft(projectName: string, sessionId: string | null) {
|
|
|
34
41
|
}
|
|
35
42
|
let cancelled = false;
|
|
36
43
|
setLoading(true);
|
|
44
|
+
// Releasing the gate only reveals the composer; a draft arriving afterwards
|
|
45
|
+
// is still applied, and MessageInput refuses to overwrite typed text.
|
|
46
|
+
const releaseTimer = setTimeout(() => {
|
|
47
|
+
if (!cancelled) setLoading(false);
|
|
48
|
+
}, GATE_RELEASE_MS);
|
|
37
49
|
api
|
|
38
50
|
.get<DraftResult | null>(
|
|
39
51
|
`${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(effectiveId)}`,
|
|
@@ -49,8 +61,11 @@ export function useDraft(projectName: string, sessionId: string | null) {
|
|
|
49
61
|
}
|
|
50
62
|
})
|
|
51
63
|
.catch(() => { if (!cancelled) setDraft(null); })
|
|
52
|
-
.finally(() => {
|
|
53
|
-
|
|
64
|
+
.finally(() => {
|
|
65
|
+
clearTimeout(releaseTimer);
|
|
66
|
+
if (!cancelled) setLoading(false);
|
|
67
|
+
});
|
|
68
|
+
return () => { cancelled = true; clearTimeout(releaseTimer); };
|
|
54
69
|
}, [projectName, effectiveId]);
|
|
55
70
|
|
|
56
71
|
// Debounced save (1s)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useGesture } from "@use-gesture/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pan / zoom / rotate / flip state for a single image inside a fixed container.
|
|
6
|
+
*
|
|
7
|
+
* The transform is written straight to the element's style on every gesture frame and only
|
|
8
|
+
* mirrored into React state for the zoom readout. Re-rendering a component tree per
|
|
9
|
+
* pointermove is what makes a viewer feel heavy, and none of the surrounding UI depends on
|
|
10
|
+
* the intermediate values.
|
|
11
|
+
*
|
|
12
|
+
* `scale` is relative to the image's laid-out size, not its pixels: the `<img>` is already
|
|
13
|
+
* CSS-fitted to the container, so scale 1 means "fits" and the fit never has to be measured
|
|
14
|
+
* for the unrotated case.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ImageTransform {
|
|
18
|
+
scale: number;
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
/** Multiples of 90 degrees, always normalised to 0/90/180/270. */
|
|
22
|
+
rotation: number;
|
|
23
|
+
flipX: boolean;
|
|
24
|
+
flipY: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IDENTITY: ImageTransform = { scale: 1, x: 0, y: 0, rotation: 0, flipX: false, flipY: false };
|
|
28
|
+
|
|
29
|
+
const MIN_SCALE = 0.1;
|
|
30
|
+
const MAX_SCALE = 20;
|
|
31
|
+
/** Scale applied when double-clicking an image that is currently at fit. */
|
|
32
|
+
const DOUBLE_CLICK_SCALE = 2.5;
|
|
33
|
+
|
|
34
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a wheel event looks like a real mouse wheel rather than a trackpad.
|
|
38
|
+
*
|
|
39
|
+
* Trackpads emit a dense stream of small, often fractional deltas and frequently move on both
|
|
40
|
+
* axes; a wheel emits sparse whole-number notches on one axis. The guess only decides whether
|
|
41
|
+
* a bare wheel zooms or pans — Ctrl/Cmd+wheel, the toolbar and the keyboard always zoom, so a
|
|
42
|
+
* wrong guess is an annoyance rather than a dead end.
|
|
43
|
+
*/
|
|
44
|
+
function looksLikeMouseWheel(e: WheelEvent): boolean {
|
|
45
|
+
if (e.deltaMode !== 0) return true; // line/page deltas only ever come from a wheel
|
|
46
|
+
if (e.deltaX !== 0) return false;
|
|
47
|
+
return Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UseImageTransformResult {
|
|
51
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
52
|
+
imageRef: React.RefObject<HTMLImageElement | null>;
|
|
53
|
+
/** Current zoom as a percentage of the fitted size, for display only. */
|
|
54
|
+
zoomPercent: number;
|
|
55
|
+
rotation: number;
|
|
56
|
+
zoomBy: (factor: number) => void;
|
|
57
|
+
reset: () => void;
|
|
58
|
+
fit: () => void;
|
|
59
|
+
actualSize: () => void;
|
|
60
|
+
rotate: (deltaDegrees: number) => void;
|
|
61
|
+
flip: (axis: "x" | "y") => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useImageTransform(): UseImageTransformResult {
|
|
65
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
66
|
+
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
67
|
+
const t = useRef<ImageTransform>({ ...IDENTITY });
|
|
68
|
+
const frame = useRef<number | null>(null);
|
|
69
|
+
|
|
70
|
+
const [zoomPercent, setZoomPercent] = useState(100);
|
|
71
|
+
const [rotation, setRotation] = useState(0);
|
|
72
|
+
|
|
73
|
+
/** Laid-out size of the image — unaffected by transforms, so it is the stable reference. */
|
|
74
|
+
const layout = useCallback(() => {
|
|
75
|
+
const img = imageRef.current;
|
|
76
|
+
const box = containerRef.current;
|
|
77
|
+
return {
|
|
78
|
+
dw: img?.clientWidth ?? 0,
|
|
79
|
+
dh: img?.clientHeight ?? 0,
|
|
80
|
+
cw: box?.clientWidth ?? 0,
|
|
81
|
+
ch: box?.clientHeight ?? 0,
|
|
82
|
+
natural: img?.naturalWidth ?? 0,
|
|
83
|
+
};
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
/** How far the image may travel before its edge crosses the container's. */
|
|
87
|
+
const panLimits = useCallback(() => {
|
|
88
|
+
const { dw, dh, cw, ch } = layout();
|
|
89
|
+
const quarterTurned = Math.abs(t.current.rotation % 180) === 90;
|
|
90
|
+
const w = (quarterTurned ? dh : dw) * t.current.scale;
|
|
91
|
+
const h = (quarterTurned ? dw : dh) * t.current.scale;
|
|
92
|
+
return { maxX: Math.max(0, (w - cw) / 2), maxY: Math.max(0, (h - ch) / 2) };
|
|
93
|
+
}, [layout]);
|
|
94
|
+
|
|
95
|
+
const commit = useCallback(() => {
|
|
96
|
+
setZoomPercent(Math.round(t.current.scale * 100));
|
|
97
|
+
setRotation(t.current.rotation);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Write the transform, and publish the readout.
|
|
102
|
+
*
|
|
103
|
+
* Mid-gesture the readout is throttled to one frame, since it is cosmetic and re-rendering
|
|
104
|
+
* per pointermove is what makes a viewer feel heavy. At rest it must be published outright:
|
|
105
|
+
* a frame callback only runs if the browser goes on to paint, so a gesture that ends the
|
|
106
|
+
* moment the finger lifts would otherwise leave the number showing the previous value.
|
|
107
|
+
*/
|
|
108
|
+
const apply = useCallback((immediate = false) => {
|
|
109
|
+
const el = imageRef.current;
|
|
110
|
+
if (!el) return;
|
|
111
|
+
const { scale, x, y, rotation: r, flipX, flipY } = t.current;
|
|
112
|
+
el.style.transform =
|
|
113
|
+
`translate3d(${x}px, ${y}px, 0) rotate(${r}deg) ` +
|
|
114
|
+
`scale(${scale * (flipX ? -1 : 1)}, ${scale * (flipY ? -1 : 1)})`;
|
|
115
|
+
|
|
116
|
+
if (immediate) {
|
|
117
|
+
if (frame.current != null) {
|
|
118
|
+
cancelAnimationFrame(frame.current);
|
|
119
|
+
frame.current = null;
|
|
120
|
+
}
|
|
121
|
+
commit();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (frame.current == null) {
|
|
125
|
+
frame.current = requestAnimationFrame(() => {
|
|
126
|
+
frame.current = null;
|
|
127
|
+
commit();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}, [commit]);
|
|
131
|
+
|
|
132
|
+
/** Clamp the pan back inside the limits — used after any scale or rotation change. */
|
|
133
|
+
const settle = useCallback((immediate = false) => {
|
|
134
|
+
const { maxX, maxY } = panLimits();
|
|
135
|
+
t.current.x = clamp(t.current.x, -maxX, maxX);
|
|
136
|
+
t.current.y = clamp(t.current.y, -maxY, maxY);
|
|
137
|
+
apply(immediate);
|
|
138
|
+
}, [apply, panLimits]);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Zoom while holding one point of the image still.
|
|
142
|
+
*
|
|
143
|
+
* `originX/Y` are client coordinates; without this correction the image slides away from
|
|
144
|
+
* whatever the user was pointing at, which is the single most noticeable flaw in a viewer.
|
|
145
|
+
*/
|
|
146
|
+
const zoomAt = useCallback((nextScale: number, originX?: number, originY?: number, immediate = false) => {
|
|
147
|
+
const box = containerRef.current;
|
|
148
|
+
if (!box) return;
|
|
149
|
+
const rect = box.getBoundingClientRect();
|
|
150
|
+
const cx = (originX ?? rect.left + rect.width / 2) - (rect.left + rect.width / 2);
|
|
151
|
+
const cy = (originY ?? rect.top + rect.height / 2) - (rect.top + rect.height / 2);
|
|
152
|
+
|
|
153
|
+
const prev = t.current.scale;
|
|
154
|
+
const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
|
155
|
+
const ratio = scale / prev;
|
|
156
|
+
t.current.scale = scale;
|
|
157
|
+
t.current.x = cx - (cx - t.current.x) * ratio;
|
|
158
|
+
t.current.y = cy - (cy - t.current.y) * ratio;
|
|
159
|
+
settle(immediate);
|
|
160
|
+
}, [settle]);
|
|
161
|
+
|
|
162
|
+
const reset = useCallback(() => {
|
|
163
|
+
t.current = { ...IDENTITY };
|
|
164
|
+
apply(true);
|
|
165
|
+
}, [apply]);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Scale the image so its rotated bounding box fits the container.
|
|
169
|
+
*
|
|
170
|
+
* At 0 and 180 degrees CSS has already fitted it, so the answer is 1. At a quarter turn the
|
|
171
|
+
* width and height swap and the fitted box no longer fits.
|
|
172
|
+
*/
|
|
173
|
+
const fit = useCallback(() => {
|
|
174
|
+
const { dw, dh, cw, ch } = layout();
|
|
175
|
+
const quarterTurned = Math.abs(t.current.rotation % 180) === 90;
|
|
176
|
+
t.current.scale = quarterTurned && dw > 0 && dh > 0 ? Math.min(cw / dh, ch / dw) : 1;
|
|
177
|
+
t.current.x = 0;
|
|
178
|
+
t.current.y = 0;
|
|
179
|
+
settle(true);
|
|
180
|
+
}, [layout, settle]);
|
|
181
|
+
|
|
182
|
+
/** One image pixel per CSS pixel. */
|
|
183
|
+
const actualSize = useCallback(() => {
|
|
184
|
+
const { dw, natural } = layout();
|
|
185
|
+
if (dw > 0 && natural > 0) zoomAt(natural / dw, undefined, undefined, true);
|
|
186
|
+
}, [layout, zoomAt]);
|
|
187
|
+
|
|
188
|
+
const rotate = useCallback((delta: number) => {
|
|
189
|
+
t.current.rotation = ((t.current.rotation + delta) % 360 + 360) % 360;
|
|
190
|
+
// A quarter turn changes which dimension is constrained, so an untouched view would
|
|
191
|
+
// otherwise overflow. Re-fitting keeps the whole image visible, which is the reason
|
|
192
|
+
// someone rotates in the first place.
|
|
193
|
+
fit();
|
|
194
|
+
}, [fit]);
|
|
195
|
+
|
|
196
|
+
const flip = useCallback((axis: "x" | "y") => {
|
|
197
|
+
if (axis === "x") t.current.flipX = !t.current.flipX;
|
|
198
|
+
else t.current.flipY = !t.current.flipY;
|
|
199
|
+
apply(true);
|
|
200
|
+
}, [apply]);
|
|
201
|
+
|
|
202
|
+
const zoomBy = useCallback(
|
|
203
|
+
(factor: number) => zoomAt(t.current.scale * factor, undefined, undefined, true),
|
|
204
|
+
[zoomAt],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Bound to the container element itself rather than returning props to spread: only a
|
|
208
|
+
// natively attached, non-passive listener may call preventDefault on wheel, and React's
|
|
209
|
+
// synthetic wheel handler is always passive.
|
|
210
|
+
useGesture(
|
|
211
|
+
{
|
|
212
|
+
onDrag: ({ delta: [dx, dy], pinching, cancel, last }) => {
|
|
213
|
+
if (pinching) return cancel();
|
|
214
|
+
t.current.x += dx;
|
|
215
|
+
t.current.y += dy;
|
|
216
|
+
settle(last);
|
|
217
|
+
},
|
|
218
|
+
onPinch: ({ offset: [s], origin: [ox, oy], first, last, memo }) => {
|
|
219
|
+
// `offset` is cumulative from the gesture's start, so anchor it to the scale we had.
|
|
220
|
+
const base = first ? t.current.scale / (s || 1) : memo ?? 1;
|
|
221
|
+
zoomAt(base * s, ox, oy, last);
|
|
222
|
+
return base;
|
|
223
|
+
},
|
|
224
|
+
onWheel: ({ event, last }) => {
|
|
225
|
+
// A wheel gesture closes with a trailing frame that carries the previous event
|
|
226
|
+
// object, deltas and all. Acting on it would apply every notch twice — but it is the
|
|
227
|
+
// right moment to publish the readout, since no further frame is guaranteed.
|
|
228
|
+
if (last) {
|
|
229
|
+
apply(true);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Read the deltas off the event rather than the gesture state, which accumulates.
|
|
233
|
+
const { deltaX, deltaY } = event;
|
|
234
|
+
if (deltaX === 0 && deltaY === 0) return;
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
|
|
237
|
+
// A trackpad pinch reaches the page as Ctrl+wheel — the browser exposes no separate
|
|
238
|
+
// pinch event outside of Safari, and @use-gesture's pinch recogniser does not claim
|
|
239
|
+
// it here, so this is the only place it can be handled.
|
|
240
|
+
if (event.ctrlKey || event.metaKey || looksLikeMouseWheel(event)) {
|
|
241
|
+
// Exponential so every notch scales by the same proportion, and so a trackpad's
|
|
242
|
+
// stream of small fractional deltas reads as one continuous zoom.
|
|
243
|
+
zoomAt(t.current.scale * Math.exp(-deltaY / 300), event.clientX, event.clientY);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
t.current.x -= deltaX;
|
|
248
|
+
t.current.y -= deltaY;
|
|
249
|
+
settle();
|
|
250
|
+
},
|
|
251
|
+
onDoubleClick: ({ event }) => {
|
|
252
|
+
if (t.current.scale > 1.05) fit();
|
|
253
|
+
else zoomAt(DOUBLE_CLICK_SCALE, event.clientX, event.clientY, true);
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
target: containerRef,
|
|
258
|
+
drag: { from: () => [t.current.x, t.current.y], filterTaps: true, pointer: { touch: true } },
|
|
259
|
+
pinch: { scaleBounds: { min: MIN_SCALE, max: MAX_SCALE }, rubberband: true },
|
|
260
|
+
eventOptions: { passive: false },
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// A resized window changes what "fits", and a stale pan clamp would leave the image
|
|
265
|
+
// stranded off-centre.
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const onResize = () => settle();
|
|
268
|
+
window.addEventListener("resize", onResize);
|
|
269
|
+
return () => {
|
|
270
|
+
window.removeEventListener("resize", onResize);
|
|
271
|
+
if (frame.current != null) cancelAnimationFrame(frame.current);
|
|
272
|
+
};
|
|
273
|
+
}, [settle]);
|
|
274
|
+
|
|
275
|
+
return { containerRef, imageRef, zoomPercent, rotation, zoomBy, reset, fit, actualSize, rotate, flip };
|
|
276
|
+
}
|
|
@@ -16,11 +16,30 @@ function warnOnAuditFailure(res: Response): void {
|
|
|
16
16
|
/** GETs currently awaiting a response, keyed by absolute URL. See ApiClient.get. */
|
|
17
17
|
const pendingGets = new Map<string, Promise<unknown>>();
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* How long a GET may wait for response headers. Browsers do not time a stalled
|
|
21
|
+
* fetch out on their own, and a request that never settles keeps its
|
|
22
|
+
* `pendingGets` slot forever — every later caller of that URL then inherits the
|
|
23
|
+
* stall, and UI gated on the response stays hidden until a page reload.
|
|
24
|
+
*
|
|
25
|
+
* The clock stops once headers arrive, so transcripts, file reads and other
|
|
26
|
+
* large bodies may still take as long as the connection needs. Generous enough
|
|
27
|
+
* for endpoints that spawn a CLI or hit a registry before answering.
|
|
28
|
+
*/
|
|
29
|
+
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
/** `reason` is only guaranteed on newer engines; never propagate `undefined`. */
|
|
32
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
33
|
+
return signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ApiClient {
|
|
20
37
|
private baseUrl: string;
|
|
38
|
+
private responseTimeoutMs: number;
|
|
21
39
|
|
|
22
|
-
constructor(baseUrl = "") {
|
|
40
|
+
constructor(baseUrl = "", responseTimeoutMs = RESPONSE_TIMEOUT_MS) {
|
|
23
41
|
this.baseUrl = baseUrl;
|
|
42
|
+
this.responseTimeoutMs = responseTimeoutMs;
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
private getToken(): string | null {
|
|
@@ -61,11 +80,35 @@ class ApiClient {
|
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
private async rawGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
84
|
+
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timer = setTimeout(
|
|
87
|
+
() =>
|
|
88
|
+
controller.abort(
|
|
89
|
+
new DOMException(
|
|
90
|
+
`No response after ${this.responseTimeoutMs}ms: ${path}`,
|
|
91
|
+
"TimeoutError",
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
this.responseTimeoutMs,
|
|
95
|
+
);
|
|
96
|
+
const forwardAbort = () => controller.abort(abortReason(signal!));
|
|
97
|
+
signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
101
|
+
headers: this.headers(),
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
// Headers are in, so the connection answered — stop the clock before
|
|
105
|
+
// reading the body, which may legitimately be large and slow.
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
return await this.handleResponse<T>(res);
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
111
|
+
}
|
|
69
112
|
}
|
|
70
113
|
|
|
71
114
|
async post<T>(path: string, body?: unknown): Promise<T> {
|