@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-ByZApv3Y.js → ai-resource-editor-QNfSU0Fi.js} +1 -1
  5. package/dist/web/assets/{audio-preview-izB0KGPQ.js → audio-preview-IokB77Cj.js} +1 -1
  6. package/dist/web/assets/chat-tab-BZNEvS88.js +13 -0
  7. package/dist/web/assets/{code-editor-Bx-Q27Nm.js → code-editor-B8YiBPOX.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-B60UimS8.js → conflict-editor-CY1o5kBO.js} +1 -1
  9. package/dist/web/assets/{csv-preview-BZv2SuCz.js → csv-preview-AEg0GB-V.js} +1 -1
  10. package/dist/web/assets/{database-viewer-B5J_4lvX.js → database-viewer-C5Meg0s_.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-DiytoNvX.js → diff-viewer-BRRGvZnp.js} +1 -1
  12. package/dist/web/assets/{docx-preview-CrjmF16z.js → docx-preview-DqTX7NDp.js} +1 -1
  13. package/dist/web/assets/{edit-diff-preview-BSjgyGAL.js → edit-diff-preview-_CXUdwsF.js} +1 -1
  14. package/dist/web/assets/{extension-webview-BSiQUxFD.js → extension-webview-CQceYb7O.js} +1 -1
  15. package/dist/web/assets/{git-log-panel-Fb5EefXW.js → git-log-panel-D7QlAJqu.js} +1 -1
  16. package/dist/web/assets/{glide-data-grid-Cjve7mDb.js → glide-data-grid-cGxkgluR.js} +1 -1
  17. package/dist/web/assets/group-chat-tab-B-eA5ytm.js +1 -0
  18. package/dist/web/assets/{image-preview-BiVYVqa9.js → image-preview-DRUzYptI.js} +1 -1
  19. package/dist/web/assets/{index-CuAijZGh.js → index-BuX7JkCt.js} +5 -5
  20. package/dist/web/assets/index-PX2pZ3bH.css +2 -0
  21. package/dist/web/assets/{keybindings-store-sakq3gsF.js → keybindings-store-B3plqyxF.js} +1 -1
  22. package/dist/web/assets/{markdown-renderer-BsKIIM83.js → markdown-renderer-BKDbjc3g.js} +1 -1
  23. package/dist/web/assets/{markdown-renderer-Cct9JHnx.js → markdown-renderer-C9Gy8tM2.js} +1 -1
  24. package/dist/web/assets/{notification-store-D4TsAnN3.js → notification-store-jFDauzse.js} +1 -1
  25. package/dist/web/assets/panel-store-D-RxcUgH.js +1 -0
  26. package/dist/web/assets/{pdf-preview-B6G1QpE6.js → pdf-preview-CzHUqXJB.js} +1 -1
  27. package/dist/web/assets/{postgres-viewer-CdFu2Yj9.js → postgres-viewer-BeantJZe.js} +1 -1
  28. package/dist/web/assets/{settings-tab-Dh3bD_dg.js → settings-tab-CqQHhqV1.js} +1 -1
  29. package/dist/web/assets/{sql-query-editor-C8XXOY2R.js → sql-query-editor-DqU9zfVy.js} +1 -1
  30. package/dist/web/assets/{sqlite-viewer-B1eXvaLK.js → sqlite-viewer-BH891DC0.js} +1 -1
  31. package/dist/web/assets/{system-monitor-tab-D2QcCXl1.js → system-monitor-tab-Bue4p4Vb.js} +1 -1
  32. package/dist/web/assets/{tab-store-ByORqglW.js → tab-store-CNxCwdXw.js} +1 -1
  33. package/dist/web/assets/{terminal-tab-D2-buBAQ.js → terminal-tab-BfNnKU71.js} +1 -1
  34. package/dist/web/assets/tool-cards-CSOwe7_i.js +7 -0
  35. package/dist/web/assets/use-blob-url-jSRmyXVg.js +1 -0
  36. package/dist/web/assets/{use-monaco-theme-DMlVAYKD.js → use-monaco-theme-5XjAzOzE.js} +1 -1
  37. package/dist/web/assets/{video-preview-Cg3A7vrt.js → video-preview-DMqLOqmc.js} +1 -1
  38. package/dist/web/index.html +4 -4
  39. package/dist/web/sw.js +1 -1
  40. package/package.json +1 -1
  41. package/skills-lock.json +11 -0
  42. package/spike-memory-region-dump.ps1 +69 -0
  43. package/src/providers/claude-agent-sdk.ts +4 -3
  44. package/src/server/routes/chat.ts +9 -3
  45. package/src/server/routes/fs-browse.ts +13 -2
  46. package/src/services/db.service.ts +25 -1
  47. package/src/services/jsonl-transcript-parser.ts +2 -1
  48. package/src/services/slash-discovery/builtin-commands.ts +7 -3
  49. package/src/services/slash-discovery/index.ts +8 -1
  50. package/src/services/slash-discovery/sdk-commands.ts +126 -0
  51. package/src/services/slash-discovery/types.ts +5 -0
  52. package/src/shared/fuzzy-search.ts +37 -5
  53. package/src/shared/image-extensions.ts +19 -0
  54. package/src/shared/tool-result-content.ts +70 -0
  55. package/src/web/components/chat/chat-tab.tsx +25 -5
  56. package/src/web/components/chat/image-preview-geometry.ts +83 -0
  57. package/src/web/components/chat/image-preview-states.tsx +51 -0
  58. package/src/web/components/chat/slash-command-picker.tsx +2 -0
  59. package/src/web/components/chat/tool-cards.tsx +27 -2
  60. package/src/web/components/chat/tool-image-preview.tsx +225 -0
  61. package/src/web/components/editor/audio-preview.tsx +1 -1
  62. package/src/web/components/editor/image-preview.tsx +1 -1
  63. package/src/web/components/editor/video-preview.tsx +1 -1
  64. package/src/web/components/layout/tab-bar.tsx +3 -3
  65. package/src/web/{components/editor → hooks}/use-blob-url.ts +14 -3
  66. package/src/web/stores/dock-actions.ts +14 -4
  67. package/src/web/stores/panel-store.ts +8 -14
  68. package/src/web/styles/globals.css +72 -0
  69. package/dist/web/assets/chat-tab-CwWL5DbD.js +0 -13
  70. package/dist/web/assets/group-chat-tab-DT0X7Cbz.js +0 -1
  71. package/dist/web/assets/index-BW2cOdzp.css +0 -2
  72. package/dist/web/assets/panel-store-DsDZX8Mi.js +0 -1
  73. package/dist/web/assets/tool-cards-DDxShmxN.js +0 -7
  74. package/dist/web/assets/use-blob-url-kvtWmJtt.js +0 -1
  75. /package/dist/web/assets/{csv-parser-BSnYgPcx.js → csv-parser-BPS94T2h.js} +0 -0
  76. /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 "./use-blob-url";
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 "./use-blob-url";
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 "./use-blob-url";
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
- * The action moves the same live session; closing later re-docks it.
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.moveTab(tab.id, effectivePanelId, DOCK_PANEL_ID);
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("Failed");
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(() => { if (!cancelled) setError(true); });
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
- return { blobUrl, error };
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
- // Called by closeTab when a terminal is closed from a GRID panel.
178
- // Closing a terminal from the grid PARKS it in the dock instead of killing it —
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 = loaded.dock ?? restoreDockForProject(projectName, newProjectDock);
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
- // Location-based re-dock: closing a terminal from ANY grid panel parks it in the
522
- // dock instead of killing it. Real kill only when closed from WITHIN the dock,
523
- // on shell exit (onExit handler), or after idle/grace expiry.
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;