@hienlh/ppm 0.17.32 → 0.17.34
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 +16 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-ByZApv3Y.js → ai-resource-editor-QNfSU0Fi.js} +1 -1
- package/dist/web/assets/{audio-preview-izB0KGPQ.js → audio-preview-IokB77Cj.js} +1 -1
- package/dist/web/assets/chat-tab-BZNEvS88.js +13 -0
- package/dist/web/assets/{code-editor-Bx-Q27Nm.js → code-editor-B8YiBPOX.js} +2 -2
- package/dist/web/assets/{conflict-editor-B60UimS8.js → conflict-editor-CY1o5kBO.js} +1 -1
- package/dist/web/assets/{csv-preview-BZv2SuCz.js → csv-preview-AEg0GB-V.js} +1 -1
- package/dist/web/assets/{database-viewer-B5J_4lvX.js → database-viewer-C5Meg0s_.js} +1 -1
- package/dist/web/assets/{diff-viewer-DiytoNvX.js → diff-viewer-BRRGvZnp.js} +1 -1
- package/dist/web/assets/{docx-preview-CrjmF16z.js → docx-preview-DqTX7NDp.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-BSjgyGAL.js → edit-diff-preview-_CXUdwsF.js} +1 -1
- package/dist/web/assets/{extension-webview-BSiQUxFD.js → extension-webview-CQceYb7O.js} +1 -1
- package/dist/web/assets/{git-log-panel-Fb5EefXW.js → git-log-panel-D7QlAJqu.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Cjve7mDb.js → glide-data-grid-cGxkgluR.js} +1 -1
- package/dist/web/assets/group-chat-tab-B-eA5ytm.js +1 -0
- package/dist/web/assets/{image-preview-BiVYVqa9.js → image-preview-DRUzYptI.js} +1 -1
- package/dist/web/assets/{index-CuAijZGh.js → index-BuX7JkCt.js} +5 -5
- package/dist/web/assets/index-PX2pZ3bH.css +2 -0
- package/dist/web/assets/{keybindings-store-sakq3gsF.js → keybindings-store-B3plqyxF.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BsKIIM83.js → markdown-renderer-BKDbjc3g.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Cct9JHnx.js → markdown-renderer-C9Gy8tM2.js} +1 -1
- package/dist/web/assets/{notification-store-D4TsAnN3.js → notification-store-jFDauzse.js} +1 -1
- package/dist/web/assets/panel-store-D-RxcUgH.js +1 -0
- package/dist/web/assets/{pdf-preview-B6G1QpE6.js → pdf-preview-CzHUqXJB.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CdFu2Yj9.js → postgres-viewer-BeantJZe.js} +1 -1
- package/dist/web/assets/{settings-tab-Dh3bD_dg.js → settings-tab-CqQHhqV1.js} +1 -1
- package/dist/web/assets/{sql-query-editor-C8XXOY2R.js → sql-query-editor-DqU9zfVy.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-B1eXvaLK.js → sqlite-viewer-BH891DC0.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-D2QcCXl1.js → system-monitor-tab-Bue4p4Vb.js} +1 -1
- package/dist/web/assets/{tab-store-ByORqglW.js → tab-store-CNxCwdXw.js} +1 -1
- package/dist/web/assets/{terminal-tab-D2-buBAQ.js → terminal-tab-BfNnKU71.js} +1 -1
- package/dist/web/assets/tool-cards-CSOwe7_i.js +7 -0
- package/dist/web/assets/use-blob-url-jSRmyXVg.js +1 -0
- package/dist/web/assets/{use-monaco-theme-DMlVAYKD.js → use-monaco-theme-5XjAzOzE.js} +1 -1
- package/dist/web/assets/{video-preview-Cg3A7vrt.js → video-preview-DMqLOqmc.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/skills-lock.json +11 -0
- package/spike-memory-region-dump.ps1 +69 -0
- package/src/providers/claude-agent-sdk.ts +4 -3
- package/src/server/routes/chat.ts +9 -3
- package/src/server/routes/fs-browse.ts +13 -2
- package/src/services/db.service.ts +25 -1
- package/src/services/jsonl-transcript-parser.ts +2 -1
- package/src/services/slash-discovery/builtin-commands.ts +7 -3
- package/src/services/slash-discovery/index.ts +8 -1
- package/src/services/slash-discovery/sdk-commands.ts +126 -0
- package/src/services/slash-discovery/types.ts +5 -0
- package/src/shared/fuzzy-search.ts +37 -5
- package/src/shared/image-extensions.ts +19 -0
- package/src/shared/tool-result-content.ts +70 -0
- package/src/web/components/chat/chat-tab.tsx +25 -5
- package/src/web/components/chat/image-preview-geometry.ts +83 -0
- package/src/web/components/chat/image-preview-states.tsx +51 -0
- package/src/web/components/chat/slash-command-picker.tsx +2 -0
- package/src/web/components/chat/tool-cards.tsx +27 -2
- package/src/web/components/chat/tool-image-preview.tsx +225 -0
- package/src/web/components/editor/audio-preview.tsx +1 -1
- package/src/web/components/editor/image-preview.tsx +1 -1
- package/src/web/components/editor/video-preview.tsx +1 -1
- package/src/web/components/layout/tab-bar.tsx +3 -3
- package/src/web/{components/editor → hooks}/use-blob-url.ts +14 -3
- package/src/web/stores/dock-actions.ts +14 -4
- package/src/web/stores/panel-store.ts +8 -14
- package/src/web/styles/globals.css +72 -0
- package/dist/web/assets/chat-tab-CwWL5DbD.js +0 -13
- package/dist/web/assets/group-chat-tab-DT0X7Cbz.js +0 -1
- package/dist/web/assets/index-BW2cOdzp.css +0 -2
- package/dist/web/assets/panel-store-DsDZX8Mi.js +0 -1
- package/dist/web/assets/tool-cards-DDxShmxN.js +0 -7
- package/dist/web/assets/use-blob-url-kvtWmJtt.js +0 -1
- /package/dist/web/assets/{csv-parser-BSnYgPcx.js → csv-parser-BPS94T2h.js} +0 -0
- /package/dist/web/assets/{libesm-Dd8zbj1v.js → libesm-BS7D9de3.js} +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useShallow } from "zustand/react/shallow";
|
|
3
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
4
|
+
import { useImageOverlay } from "@/stores/image-overlay-store";
|
|
5
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
6
|
+
import { copyToClipboard } from "@/lib/clipboard";
|
|
7
|
+
import { basename } from "@/lib/utils";
|
|
8
|
+
import { ImagePreviewFailure, ImagePreviewSkeleton } from "./image-preview-states";
|
|
9
|
+
import {
|
|
10
|
+
cacheNatural,
|
|
11
|
+
dimensionsLabel,
|
|
12
|
+
formatBytes,
|
|
13
|
+
getCachedNatural,
|
|
14
|
+
previewLayout,
|
|
15
|
+
TILE_WIDTH,
|
|
16
|
+
type NaturalSize,
|
|
17
|
+
} from "./image-preview-geometry";
|
|
18
|
+
|
|
19
|
+
/** Ratio the skeleton assumes before the image has ever been measured. */
|
|
20
|
+
const FALLBACK_ASPECT = 4 / 5;
|
|
21
|
+
|
|
22
|
+
const LABEL = "text-[var(--img-meta-label)] @max-[470px]:hidden";
|
|
23
|
+
const VALUE = "truncate text-text @max-[470px]:text-text-2";
|
|
24
|
+
const GHOST =
|
|
25
|
+
"h-[30px] @max-[470px]:h-[36px] shrink-0 rounded-[7px] px-[9px] font-mono text-[11.5px] " +
|
|
26
|
+
"text-text-2 bg-[var(--img-ghost)] border border-[var(--img-ghost-border)] backdrop-blur-[6px] " +
|
|
27
|
+
"transition-colors can-hover:hover:bg-[var(--img-ghost-hover)] " +
|
|
28
|
+
"can-hover:hover:border-[var(--img-ghost-hover-border)] can-hover:hover:text-text";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The image a Read call opened, shown on a stage lit by a blurred copy of itself, beside
|
|
32
|
+
* the file facts needed to decide whether to open it. Fetched as a blob with an
|
|
33
|
+
* Authorization header, so no auth token ends up in an image URL.
|
|
34
|
+
*/
|
|
35
|
+
export function ToolImagePreview({
|
|
36
|
+
filePath,
|
|
37
|
+
projectName,
|
|
38
|
+
}: {
|
|
39
|
+
filePath: string;
|
|
40
|
+
projectName: string;
|
|
41
|
+
}) {
|
|
42
|
+
const [attempt, setAttempt] = useState(0);
|
|
43
|
+
const { blobUrl, blob, error, errorCode } = useBlobUrl(filePath, projectName, undefined, attempt);
|
|
44
|
+
const [natural, setNatural] = useState<NaturalSize | undefined>(() => getCachedNatural(filePath));
|
|
45
|
+
const [decodeFailed, setDecodeFailed] = useState(false);
|
|
46
|
+
const [copied, setCopied] = useState(false);
|
|
47
|
+
const openOverlay = useImageOverlay((s) => s.open);
|
|
48
|
+
const { openTab } = useTabStore(useShallow((s) => ({ openTab: s.openTab })));
|
|
49
|
+
const name = basename(filePath);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!copied) return;
|
|
53
|
+
const timer = setTimeout(() => setCopied(false), 1200);
|
|
54
|
+
return () => clearTimeout(timer);
|
|
55
|
+
}, [copied]);
|
|
56
|
+
|
|
57
|
+
const measure = (img: HTMLImageElement) => {
|
|
58
|
+
const size = { w: img.naturalWidth, h: img.naturalHeight };
|
|
59
|
+
if (!size.w || !size.h) return setDecodeFailed(true);
|
|
60
|
+
cacheNatural(filePath, size);
|
|
61
|
+
setNatural(size);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const retry = () => {
|
|
65
|
+
setDecodeFailed(false);
|
|
66
|
+
setAttempt((n) => n + 1);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (error || decodeFailed) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="border-t border-border pt-1.5">
|
|
72
|
+
<ImagePreviewFailure code={decodeFailed ? undefined : errorCode ?? undefined} onRetry={retry} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const layout = natural ? previewLayout(natural, TILE_WIDTH) : null;
|
|
78
|
+
const skeletonAspect = layout?.aspect ?? FALLBACK_ASPECT;
|
|
79
|
+
|
|
80
|
+
const actions = (
|
|
81
|
+
<div className="flex flex-wrap gap-[6px]">
|
|
82
|
+
<button type="button" onClick={openInEditor} className={GHOST}>
|
|
83
|
+
<span className="@max-[470px]:hidden">open in editor</span>
|
|
84
|
+
<span className="hidden @max-[470px]:inline">open</span>
|
|
85
|
+
</button>
|
|
86
|
+
<button type="button" onClick={copyPath} className={GHOST}>
|
|
87
|
+
{copied ? "copied" : "copy path"}
|
|
88
|
+
</button>
|
|
89
|
+
<button type="button" onClick={openFull} className={GHOST}>
|
|
90
|
+
full size
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
function openInEditor() {
|
|
96
|
+
if (!projectName) return;
|
|
97
|
+
openTab({
|
|
98
|
+
type: "editor",
|
|
99
|
+
title: name,
|
|
100
|
+
metadata: { filePath, projectName },
|
|
101
|
+
projectId: projectName,
|
|
102
|
+
closable: true,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function copyPath() {
|
|
107
|
+
void copyToClipboard(filePath).then(() => setCopied(true));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function openFull() {
|
|
111
|
+
if (blobUrl) openOverlay(blobUrl, name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Loading: the same box as the loaded state, so the transcript never shifts under the
|
|
115
|
+
// reader once the image resolves.
|
|
116
|
+
if (!blobUrl || !layout || !natural) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="@container border-t border-border pt-1.5">
|
|
119
|
+
{blobUrl && (
|
|
120
|
+
<img
|
|
121
|
+
src={blobUrl}
|
|
122
|
+
alt=""
|
|
123
|
+
className="hidden"
|
|
124
|
+
onLoad={(e) => measure(e.currentTarget)}
|
|
125
|
+
onError={() => setDecodeFailed(true)}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
<ImagePreviewSkeleton aspect={skeletonAspect} actions={actions} />
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const metadata = (
|
|
134
|
+
<>
|
|
135
|
+
<dt className={LABEL}>dimensions</dt>
|
|
136
|
+
<dd className={VALUE}>{dimensionsLabel(natural, layout.fit)}</dd>
|
|
137
|
+
{blob && (
|
|
138
|
+
<>
|
|
139
|
+
<dt className={LABEL}>size</dt>
|
|
140
|
+
<dd className={VALUE}>
|
|
141
|
+
{formatBytes(blob.size)}
|
|
142
|
+
{blob.type ? ` · ${blob.type}` : ""}
|
|
143
|
+
</dd>
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
146
|
+
<dt className={LABEL}>path</dt>
|
|
147
|
+
{/* rtl puts the ellipsis at the head of the path, keeping the filename readable */}
|
|
148
|
+
<dd dir="rtl" className={`${VALUE} text-left`}>
|
|
149
|
+
{filePath}
|
|
150
|
+
</dd>
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const picture = (
|
|
155
|
+
<img
|
|
156
|
+
src={blobUrl}
|
|
157
|
+
alt={name}
|
|
158
|
+
onLoad={(e) => measure(e.currentTarget)}
|
|
159
|
+
onError={() => setDecodeFailed(true)}
|
|
160
|
+
className={
|
|
161
|
+
layout.fit === "cover"
|
|
162
|
+
? "size-full object-cover"
|
|
163
|
+
: layout.fit === "contain"
|
|
164
|
+
? "size-full object-contain"
|
|
165
|
+
: "max-h-full max-w-full"
|
|
166
|
+
}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className="@container border-t border-border pt-1.5">
|
|
172
|
+
<div className="relative overflow-hidden rounded-lg border border-border p-[14px] @max-[470px]:p-[11px]">
|
|
173
|
+
<img
|
|
174
|
+
aria-hidden="true"
|
|
175
|
+
alt=""
|
|
176
|
+
src={blobUrl}
|
|
177
|
+
className="absolute inset-0 size-full object-cover"
|
|
178
|
+
style={{ filter: "var(--img-backdrop)", transform: "scale(1.3)" }}
|
|
179
|
+
/>
|
|
180
|
+
<div className="absolute inset-0" style={{ background: "var(--img-veil)" }} />
|
|
181
|
+
|
|
182
|
+
{layout.variant === "band" ? (
|
|
183
|
+
<div className="relative grid gap-[11px]">
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={openFull}
|
|
187
|
+
title={`Open ${name}`}
|
|
188
|
+
className="img-checkerboard w-full cursor-zoom-in overflow-hidden rounded-lg border border-[var(--img-plate-border)]"
|
|
189
|
+
style={{ aspectRatio: layout.aspect }}
|
|
190
|
+
>
|
|
191
|
+
{picture}
|
|
192
|
+
</button>
|
|
193
|
+
<dl className="grid grid-cols-[auto_auto_1fr] gap-x-[18px] font-mono text-[11.5px] [&_dt]:hidden">
|
|
194
|
+
{metadata}
|
|
195
|
+
</dl>
|
|
196
|
+
{actions}
|
|
197
|
+
</div>
|
|
198
|
+
) : (
|
|
199
|
+
<div className="relative grid grid-cols-[132px_1fr] items-start gap-[14px] @max-[470px]:grid-cols-[64px_1fr] @max-[470px]:gap-[11px]">
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={openFull}
|
|
203
|
+
title={`Open ${name}`}
|
|
204
|
+
className={`cursor-zoom-in overflow-hidden rounded-lg border border-[var(--img-plate-border)] bg-[var(--img-plate)] shadow-[var(--img-plate-shadow)] ${
|
|
205
|
+
layout.fit === "cover" ? "" : "img-checkerboard flex items-center justify-center"
|
|
206
|
+
}`}
|
|
207
|
+
style={{ aspectRatio: layout.aspect }}
|
|
208
|
+
>
|
|
209
|
+
{picture}
|
|
210
|
+
</button>
|
|
211
|
+
<div className="flex min-w-0 flex-col gap-[9px]">
|
|
212
|
+
<p className="truncate text-[13px] font-semibold text-text @max-[470px]:hidden">
|
|
213
|
+
{name}
|
|
214
|
+
</p>
|
|
215
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-[14px] gap-y-[4px] font-mono text-[11.5px] @max-[470px]:grid-cols-[1fr]">
|
|
216
|
+
{metadata}
|
|
217
|
+
</dl>
|
|
218
|
+
{actions}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Loader2, FileWarning, Music } from "lucide-react";
|
|
2
|
-
import { useBlobUrl } from "
|
|
2
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
3
3
|
import { basename } from "@/lib/utils";
|
|
4
4
|
|
|
5
5
|
export function AudioPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Loader2, FileWarning } from "lucide-react";
|
|
2
|
-
import { useBlobUrl } from "
|
|
2
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
3
3
|
|
|
4
4
|
export function ImagePreview({ filePath, projectName }: { filePath: string; projectName: string }) {
|
|
5
5
|
const { blobUrl, error } = useBlobUrl(filePath, projectName);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Loader2, FileWarning } from "lucide-react";
|
|
2
|
-
import { useBlobUrl } from "
|
|
2
|
+
import { useBlobUrl } from "@/hooks/use-blob-url";
|
|
3
3
|
|
|
4
4
|
export function VideoPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
|
|
5
5
|
const { blobUrl, error } = useBlobUrl(filePath, projectName);
|
|
@@ -225,7 +225,8 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
|
|
|
225
225
|
|
|
226
226
|
/**
|
|
227
227
|
* Terminal-only "Move to Dock" / "Move to Editor" item.
|
|
228
|
-
*
|
|
228
|
+
* Reparents the same live session — the only way to keep a terminal running
|
|
229
|
+
* outside its current slot, since closing the tab ends it.
|
|
229
230
|
*/
|
|
230
231
|
function dockMoveMenuItems(tab: Tab): React.ReactNode {
|
|
231
232
|
if (tab.type !== "terminal") return null;
|
|
@@ -253,8 +254,7 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
|
|
|
253
254
|
break;
|
|
254
255
|
case "move-to-dock":
|
|
255
256
|
// Park the terminal in the dock (same live session — reparented, no restart).
|
|
256
|
-
panelState.
|
|
257
|
-
panelState.setDockVisible(true);
|
|
257
|
+
panelState.redockTab(tab.id, effectivePanelId);
|
|
258
258
|
break;
|
|
259
259
|
case "move-to-editor": {
|
|
260
260
|
// Send a dock terminal back to a grid panel: prefer the focused grid
|
|
@@ -11,11 +11,16 @@ export function useBlobUrl(
|
|
|
11
11
|
refreshKey = 0,
|
|
12
12
|
) {
|
|
13
13
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
14
|
+
const [blob, setBlob] = useState<Blob | null>(null);
|
|
14
15
|
const [error, setError] = useState(false);
|
|
16
|
+
const [errorCode, setErrorCode] = useState<string | null>(null);
|
|
15
17
|
const urlRef = useRef<string | null>(null);
|
|
16
18
|
|
|
17
19
|
useEffect(() => {
|
|
18
20
|
let cancelled = false;
|
|
21
|
+
// Reset so a retry (bumped refreshKey) leaves the error state.
|
|
22
|
+
setError(false);
|
|
23
|
+
setErrorCode(null);
|
|
19
24
|
const isExternal = /^(\/|[A-Za-z]:[/\\])/.test(filePath);
|
|
20
25
|
const url = isExternal
|
|
21
26
|
? `/api/fs/raw?path=${encodeURIComponent(filePath)}`
|
|
@@ -23,7 +28,7 @@ export function useBlobUrl(
|
|
|
23
28
|
const token = getAuthToken();
|
|
24
29
|
fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
|
25
30
|
.then((r) => {
|
|
26
|
-
if (!r.ok) throw new Error(
|
|
31
|
+
if (!r.ok) throw new Error(String(r.status));
|
|
27
32
|
return r.blob();
|
|
28
33
|
})
|
|
29
34
|
.then((blob) => {
|
|
@@ -34,8 +39,13 @@ export function useBlobUrl(
|
|
|
34
39
|
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
|
|
35
40
|
urlRef.current = u;
|
|
36
41
|
setBlobUrl(u);
|
|
42
|
+
setBlob(final);
|
|
37
43
|
})
|
|
38
|
-
.catch(() => {
|
|
44
|
+
.catch((e: Error) => {
|
|
45
|
+
if (cancelled) return;
|
|
46
|
+
setError(true);
|
|
47
|
+
setErrorCode(/^\d+$/.test(e.message) ? e.message : "network");
|
|
48
|
+
});
|
|
39
49
|
return () => { cancelled = true; };
|
|
40
50
|
}, [filePath, projectName, mimeOverride, refreshKey]);
|
|
41
51
|
|
|
@@ -44,5 +54,6 @@ export function useBlobUrl(
|
|
|
44
54
|
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
|
|
45
55
|
}, []);
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
// `blob` is exposed so callers can read size and MIME type without a second request.
|
|
58
|
+
return { blobUrl, blob, error, errorCode };
|
|
48
59
|
}
|
|
@@ -174,10 +174,8 @@ export function makeOpenInDock(set: Set, get: Get) {
|
|
|
174
174
|
// ---------------------------------------------------------------------------
|
|
175
175
|
// Re-dock: move a terminal tab from a grid panel back to __dock__ + show dock.
|
|
176
176
|
//
|
|
177
|
-
//
|
|
178
|
-
// Closing a
|
|
179
|
-
// real kill only happens when closed from WITHIN the dock, on shell exit, or
|
|
180
|
-
// after the idle/grace period expires.
|
|
177
|
+
// Explicit user action only ("Move to Dock"): it parks a live terminal without
|
|
178
|
+
// restarting its PTY. Closing a tab never routes here — close ends the session.
|
|
181
179
|
//
|
|
182
180
|
// IMPORTANT: this function MUST NOT call closeTab (loop guard). It uses the
|
|
183
181
|
// low-level moveTab primitive directly via the store getter.
|
|
@@ -245,3 +243,15 @@ export function restoreDockForProject(
|
|
|
245
243
|
): DockState {
|
|
246
244
|
return projectDock[projectName] ?? { visible: false, height: 30 };
|
|
247
245
|
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Collapse a persisted "visible" dock on mobile.
|
|
249
|
+
*
|
|
250
|
+
* Mobile renders the dock as a full-width bottom sheet covering the tab bar, so
|
|
251
|
+
* restoring a visible dock — persisted locally or synced from a desktop session —
|
|
252
|
+
* would pop the sheet open over the workspace before the user asks for it. The
|
|
253
|
+
* sheet only opens from the nav's terminal button.
|
|
254
|
+
*/
|
|
255
|
+
export function collapseRestoredDockOnMobile(dock: DockState, isMobile: boolean): DockState {
|
|
256
|
+
return isMobile && dock.visible ? { ...dock, visible: false } : dock;
|
|
257
|
+
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
persistDock,
|
|
27
27
|
snapshotDockForProject,
|
|
28
28
|
restoreDockForProject,
|
|
29
|
+
collapseRestoredDockOnMobile,
|
|
29
30
|
activeProjectDockTabCount,
|
|
30
31
|
} from "./dock-actions";
|
|
31
32
|
|
|
@@ -284,7 +285,10 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
284
285
|
const mergedPanels = { ...panels, ...migratedPanels, [DOCK_PANEL_ID]: mergedDockPanel };
|
|
285
286
|
newProjectGrids[projectName] = loaded.grid;
|
|
286
287
|
newProjectFocused[projectName] = loaded.focusedPanelId;
|
|
287
|
-
const restoredDock =
|
|
288
|
+
const restoredDock = collapseRestoredDockOnMobile(
|
|
289
|
+
loaded.dock ?? restoreDockForProject(projectName, newProjectDock),
|
|
290
|
+
get().isMobile(),
|
|
291
|
+
);
|
|
288
292
|
set({
|
|
289
293
|
currentProject: projectName,
|
|
290
294
|
panels: mergedPanels,
|
|
@@ -518,19 +522,9 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
518
522
|
if (!panel) return;
|
|
519
523
|
const pid = panel.id;
|
|
520
524
|
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
// Decision is purely location-based — no Tab.home flag needed.
|
|
525
|
-
if (tabId.startsWith("terminal:") && pid !== DOCK_PANEL_ID) {
|
|
526
|
-
// Park in dock — redockTab moves the tab object and shows the dock.
|
|
527
|
-
// Does NOT strip the localStorage session key (PTY stays alive).
|
|
528
|
-
// redockTab calls moveTab, never closeTab → no recursion.
|
|
529
|
-
get().redockTab(tabId, pid);
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Real-close path: terminal closed from dock, or non-terminal tab.
|
|
525
|
+
// Closing a terminal ends it, from a grid panel and from the dock alike.
|
|
526
|
+
// Parking a live session is an explicit action ("Move to Dock" / redockTab),
|
|
527
|
+
// never a side effect of close.
|
|
534
528
|
// Clear persisted terminal session so reopening creates a fresh PTY.
|
|
535
529
|
if (tabId.startsWith("terminal:")) {
|
|
536
530
|
try { localStorage.removeItem(`ppm:terminal-session:${tabId}`); } catch { /* */ }
|
|
@@ -101,6 +101,78 @@
|
|
|
101
101
|
--blur: 16px;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/*
|
|
105
|
+
* Ambient image stage (chat Read-tool image results). The panel floats a tile on a
|
|
106
|
+
* blurred copy of the image itself, so its surfaces are translucent veils rather than
|
|
107
|
+
* theme tokens. Declared per mode because the veil must stay contrast-safe over both a
|
|
108
|
+
* bright poster and a dark screenshot. Dark lives in :root, matching the default above.
|
|
109
|
+
*/
|
|
110
|
+
:root {
|
|
111
|
+
--img-veil: linear-gradient(100deg, rgba(10, 16, 32, 0.6), rgba(10, 16, 32, 0.84));
|
|
112
|
+
--img-backdrop: blur(40px) saturate(1.6);
|
|
113
|
+
--img-plate: rgba(255, 255, 255, 0.06);
|
|
114
|
+
--img-plate-border: rgba(148, 178, 255, 0.22);
|
|
115
|
+
--img-plate-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), 0 16px 34px -20px rgba(0, 0, 0, 0.7);
|
|
116
|
+
--img-ghost: rgba(255, 255, 255, 0.08);
|
|
117
|
+
--img-ghost-border: rgba(255, 255, 255, 0.14);
|
|
118
|
+
--img-ghost-hover: rgba(255, 255, 255, 0.14);
|
|
119
|
+
--img-ghost-hover-border: rgba(148, 178, 255, 0.35);
|
|
120
|
+
--img-meta-label: #8b97b4;
|
|
121
|
+
--img-checker: rgba(255, 255, 255, 0.04);
|
|
122
|
+
/* Own values rather than a surface token: on flat light themes panel and panel-2 are
|
|
123
|
+
both #ffffff, which would make the skeleton invisible on the card. */
|
|
124
|
+
--img-skeleton: rgba(255, 255, 255, 0.09);
|
|
125
|
+
--img-skeleton-sweep: rgba(255, 255, 255, 0.14);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
html.light {
|
|
129
|
+
--img-veil: linear-gradient(100deg, rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.84));
|
|
130
|
+
--img-backdrop: blur(40px) saturate(1.6) brightness(0.95);
|
|
131
|
+
--img-plate: rgba(255, 255, 255, 0.5);
|
|
132
|
+
--img-plate-border: rgba(255, 255, 255, 0.7);
|
|
133
|
+
--img-plate-shadow: 0 2px 4px rgba(15, 23, 42, 0.14), 0 16px 34px -20px rgba(15, 23, 42, 0.55);
|
|
134
|
+
--img-ghost: rgba(255, 255, 255, 0.7);
|
|
135
|
+
--img-ghost-border: rgba(255, 255, 255, 0.85);
|
|
136
|
+
--img-ghost-hover: rgba(255, 255, 255, 0.95);
|
|
137
|
+
--img-ghost-hover-border: #cbd5e1;
|
|
138
|
+
--img-meta-label: #7c8899;
|
|
139
|
+
--img-checker: rgba(15, 23, 42, 0.03);
|
|
140
|
+
--img-skeleton: rgba(15, 23, 42, 0.07);
|
|
141
|
+
--img-skeleton-sweep: rgba(255, 255, 255, 0.75);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Backing for image tiles that letterbox: shows the transparent area without reading
|
|
145
|
+
as an empty box. Padding keeps the artwork off the tile edge. */
|
|
146
|
+
.img-checkerboard {
|
|
147
|
+
background-image:
|
|
148
|
+
linear-gradient(45deg, var(--img-checker) 25%, transparent 25%, transparent 75%, var(--img-checker) 75%),
|
|
149
|
+
linear-gradient(45deg, var(--img-checker) 25%, transparent 25%, transparent 75%, var(--img-checker) 75%);
|
|
150
|
+
background-size: 14px 14px;
|
|
151
|
+
background-position: 0 0, 7px 7px;
|
|
152
|
+
padding: 6px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Skeleton that occupies the loaded box exactly, so the row never grows after paint. */
|
|
156
|
+
.img-skeleton {
|
|
157
|
+
position: relative;
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
background: var(--img-skeleton);
|
|
160
|
+
}
|
|
161
|
+
.img-skeleton::after {
|
|
162
|
+
content: "";
|
|
163
|
+
position: absolute;
|
|
164
|
+
inset: 0;
|
|
165
|
+
background: linear-gradient(100deg, transparent 20%, var(--img-skeleton-sweep) 50%, transparent 80%);
|
|
166
|
+
animation: img-skeleton-sweep 1.25s linear infinite;
|
|
167
|
+
}
|
|
168
|
+
@keyframes img-skeleton-sweep {
|
|
169
|
+
from { transform: translateX(-100%); }
|
|
170
|
+
to { transform: translateX(100%); }
|
|
171
|
+
}
|
|
172
|
+
@media (prefers-reduced-motion: reduce) {
|
|
173
|
+
.img-skeleton::after { animation: none; }
|
|
174
|
+
}
|
|
175
|
+
|
|
104
176
|
/* Smooth theme transitions (~250ms) */
|
|
105
177
|
html {
|
|
106
178
|
transition: background-color 0.25s ease-out, color 0.25s ease-out;
|