ompchamber 1.0.0 → 1.2.0
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/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +35 -9
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +40 -4
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Full-history user-turn index for the timeline jump rail.
|
|
8
|
+
*
|
|
9
|
+
* The timeline mounts a window of a long session (newest 120 messages plus
|
|
10
|
+
* whatever "Load earlier messages" paged in), so a rail built from the mounted
|
|
11
|
+
* rows lists only that fraction — the bug this hook exists to fix. The index
|
|
12
|
+
* comes from `/api/chat/:id/turns`, which walks the whole session with the same
|
|
13
|
+
* id/merge pipeline as the timeline, so a rail tick always resolves to a row
|
|
14
|
+
* the timeline can actually reach by paging.
|
|
15
|
+
*
|
|
16
|
+
* Live rows the committed history does not carry yet (an optimistic send in
|
|
17
|
+
* flight) are appended with `index: -1` — they are on screen already, and
|
|
18
|
+
* dropping them would make the rail lag one turn behind the composer.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
22
|
+
import { TURN_PREVIEW_CHARS } from '@/shared/lib/chat/timeline/turns';
|
|
23
|
+
import type { ChatMessageData, UserTurnRef } from '@/shared/types';
|
|
24
|
+
|
|
25
|
+
/** Appended live rows are already mounted; the jump reads this as "no paging
|
|
26
|
+
* needed" rather than as a position in the committed list. */
|
|
27
|
+
const LIVE_TURN_INDEX = -1;
|
|
28
|
+
|
|
29
|
+
export interface UserTurnsState {
|
|
30
|
+
/** Every user turn of the session, oldest first. */
|
|
31
|
+
turns: UserTurnRef[];
|
|
32
|
+
/** A jump is walking history to reach the clicked turn. */
|
|
33
|
+
jumping: boolean;
|
|
34
|
+
/** Resolve a turn's row and bring it into view; false when unreachable. */
|
|
35
|
+
jumpToTurn: (id: string, index: number) => Promise<boolean>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseUserTurnsDeps {
|
|
39
|
+
sessionId: string | null;
|
|
40
|
+
/** Mounted timeline: its user rows are merged into the index so a just-sent
|
|
41
|
+
* turn is on the rail before the committed history carries it — and they
|
|
42
|
+
* ARE the index until the fetch lands, so the rail is never blank on a
|
|
43
|
+
* session that already shows turns. */
|
|
44
|
+
messages: ChatMessageData[];
|
|
45
|
+
/** Pages history until the turn's row is mounted (session-pagination). */
|
|
46
|
+
jumpToTurn: (id: string, index: number) => Promise<boolean>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How long a jump waits for the row it paged in to commit. A single prepend
|
|
50
|
+
* can add hundreds of rows, so this is wall-clock bounded rather than
|
|
51
|
+
* frame-counted — 30 frames is not enough to lay out a large window. */
|
|
52
|
+
const ROW_COMMIT_TIMEOUT_MS = 3000;
|
|
53
|
+
|
|
54
|
+
/** Wait for a row the paging walk just requested to commit, then center it.
|
|
55
|
+
* The prepend and the jump's own scroll are separate commits: the walk
|
|
56
|
+
* resolves before the DOM has the node, so this polls until it appears
|
|
57
|
+
* instead of scrolling to a row that does not exist yet. */
|
|
58
|
+
function scrollToRow(id: string, deadline = Date.now() + ROW_COMMIT_TIMEOUT_MS): void {
|
|
59
|
+
const el = document.getElementById(id);
|
|
60
|
+
if (el) {
|
|
61
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (Date.now() >= deadline) return;
|
|
65
|
+
requestAnimationFrame(() => scrollToRow(id, deadline));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useUserTurns(deps: UseUserTurnsDeps): UserTurnsState {
|
|
69
|
+
const { sessionId, messages, jumpToTurn } = deps;
|
|
70
|
+
const [indexed, setIndexed] = useState<UserTurnRef[] | null>(null);
|
|
71
|
+
const [jumping, setJumping] = useState(false);
|
|
72
|
+
|
|
73
|
+
// Mounted user rows, as rail entries. `index: -1` says "already on screen" —
|
|
74
|
+
// true by construction here, and true for the mounted rows the full index
|
|
75
|
+
// does not carry yet (a live send, a turn committed after the fetch).
|
|
76
|
+
const mountedTurns = useMemo(() => {
|
|
77
|
+
const rows: UserTurnRef[] = [];
|
|
78
|
+
for (const message of messages) {
|
|
79
|
+
if (message.role !== 'user') continue;
|
|
80
|
+
rows.push({
|
|
81
|
+
id: message.id,
|
|
82
|
+
index: LIVE_TURN_INDEX,
|
|
83
|
+
preview: (message.content ?? '').slice(0, TURN_PREVIEW_CHARS),
|
|
84
|
+
date: message.date,
|
|
85
|
+
timestamp: message.timestamp,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return rows;
|
|
89
|
+
}, [messages]);
|
|
90
|
+
|
|
91
|
+
// The last mounted user row id is the cheapest "the committed history moved"
|
|
92
|
+
// signal: a send, a commit reconciliation, or an undo/rewind changes it,
|
|
93
|
+
// while paging older windows does not — so the index refetches exactly when
|
|
94
|
+
// its content can have gone stale.
|
|
95
|
+
const lastUserId = useMemo(() => {
|
|
96
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
97
|
+
if (messages[i]?.role === 'user') return messages[i].id;
|
|
98
|
+
}
|
|
99
|
+
return '';
|
|
100
|
+
}, [messages]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!sessionId || sessionId.startsWith('new-')) {
|
|
104
|
+
setIndexed(null);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let active = true;
|
|
108
|
+
fetch(`/api/chat/${encodeURIComponent(sessionId)}/turns`)
|
|
109
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
110
|
+
.then((data: { turns?: UserTurnRef[] } | null) => {
|
|
111
|
+
if (!active || !data || !Array.isArray(data.turns)) return;
|
|
112
|
+
setIndexed(data.turns);
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {});
|
|
115
|
+
return () => {
|
|
116
|
+
active = false;
|
|
117
|
+
};
|
|
118
|
+
}, [sessionId, lastUserId]);
|
|
119
|
+
|
|
120
|
+
const turns = useMemo(() => {
|
|
121
|
+
// Until the index lands the mounted rows ARE the rail: a session that
|
|
122
|
+
// already shows turns never renders an empty rail.
|
|
123
|
+
if (!indexed) return mountedTurns;
|
|
124
|
+
const known = new Set(indexed.map((turn) => turn.id));
|
|
125
|
+
// Rows the index does not carry yet (a live send, a turn committed after
|
|
126
|
+
// the fetch) are already mounted, so they belong on the rail too.
|
|
127
|
+
const extra = mountedTurns.filter((turn) => !known.has(turn.id));
|
|
128
|
+
return extra.length > 0 ? [...indexed, ...extra] : indexed;
|
|
129
|
+
}, [indexed, mountedTurns]);
|
|
130
|
+
|
|
131
|
+
const jump = async (id: string, index: number): Promise<boolean> => {
|
|
132
|
+
// A row with no committed position is mounted by definition, and so is one
|
|
133
|
+
// inside the loaded window — both only need the scroll below.
|
|
134
|
+
if (index < 0) {
|
|
135
|
+
scrollToRow(id);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
setJumping(true);
|
|
139
|
+
try {
|
|
140
|
+
const reached = await jumpToTurn(id, index);
|
|
141
|
+
if (reached) scrollToRow(id);
|
|
142
|
+
return reached;
|
|
143
|
+
} finally {
|
|
144
|
+
setJumping(false);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return { turns, jumping, jumpToTurn: jump };
|
|
149
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Browser file actions shared by the editor surfaces. `saveBlob` is the one
|
|
8
|
+
* place that builds and clicks the download anchor, so the text path (a Blob
|
|
9
|
+
* over the buffer) and the image path (a Blob over the fetched bytes) cannot
|
|
10
|
+
* drift apart — the image case has no text buffer at all, which is why it
|
|
11
|
+
* needs the URL variant.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Hand `blob` to the browser as a download named `name`. */
|
|
15
|
+
export function saveBlob(blob: Blob, name: string): void {
|
|
16
|
+
const url = URL.createObjectURL(blob);
|
|
17
|
+
const anchor = document.createElement('a');
|
|
18
|
+
anchor.href = url;
|
|
19
|
+
anchor.download = name;
|
|
20
|
+
anchor.click();
|
|
21
|
+
URL.revokeObjectURL(url);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Download a URL's bytes under `name`. Used for images, whose content never
|
|
26
|
+
* reaches the editor buffer — the file is fetched at download time so a large
|
|
27
|
+
* picture is never held in memory just because its tab is open.
|
|
28
|
+
*/
|
|
29
|
+
export async function downloadUrl(url: string, name: string): Promise<void> {
|
|
30
|
+
const res = await fetch(url);
|
|
31
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
32
|
+
saveBlob(await res.blob(), name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Put an image on the clipboard as a picture, not as a filename. The async
|
|
37
|
+
* clipboard API is only reachable over HTTPS/localhost and requires the image
|
|
38
|
+
* MIME to be in the browser's clipboard allow-list here — both fail by
|
|
39
|
+
* rejecting, which is the caller's signal that nothing was copied.
|
|
40
|
+
*/
|
|
41
|
+
export async function copyImageToClipboard(url: string): Promise<void> {
|
|
42
|
+
const res = await fetch(url);
|
|
43
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
44
|
+
const blob = await res.blob();
|
|
45
|
+
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) {
|
|
46
|
+
throw new Error('Image clipboard unavailable');
|
|
47
|
+
}
|
|
48
|
+
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
|
49
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
|
|
3
|
+
import { getImageMimeType } from '@/shared/lib/fs/file-kind';
|
|
4
|
+
import { buildFsRawUrl } from '@/shared/lib/fs/paths';
|
|
5
|
+
import { useFileTransfer } from '@/client/hooks/editor/use-file-transfer';
|
|
3
6
|
|
|
4
7
|
export type FileEditorSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
|
5
8
|
|
|
@@ -37,6 +40,10 @@ export interface UseFileEditorResult {
|
|
|
37
40
|
copied: boolean;
|
|
38
41
|
isDirty: boolean;
|
|
39
42
|
language: string;
|
|
43
|
+
/** True for raster images: the surface renders `imageUrl`, not `content`. */
|
|
44
|
+
isImage: boolean;
|
|
45
|
+
/** Raw-byte URL of the active image (cache-busted per revision), else null. */
|
|
46
|
+
imageUrl: string | null;
|
|
40
47
|
saveNow: () => void;
|
|
41
48
|
copy: () => void;
|
|
42
49
|
download: () => void;
|
|
@@ -78,7 +85,6 @@ export function useFileEditor(
|
|
|
78
85
|
const [isLoading, setIsLoading] = useState(false);
|
|
79
86
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
80
87
|
const [saveStatus, setSaveStatus] = useState<FileEditorSaveStatus>('idle');
|
|
81
|
-
const [copied, setCopied] = useState(false);
|
|
82
88
|
/** Bumped by `reset()` to re-run the read effect for the active file. */
|
|
83
89
|
const [revision, setRevision] = useState(0);
|
|
84
90
|
|
|
@@ -86,6 +92,15 @@ export function useFileEditor(
|
|
|
86
92
|
const content = target ? (contents[key] ?? target.content ?? '') : '';
|
|
87
93
|
const isDirty = content !== (baselines[key] ?? content);
|
|
88
94
|
|
|
95
|
+
// Extension-keyed, matching the server's own classification: an image tab
|
|
96
|
+
// never reads a text buffer and never writes one back. A target without a
|
|
97
|
+
// path has no bytes to serve, and one that already carries inline `content`
|
|
98
|
+
// (an attachment chip) has its text in hand — both stay on the text path.
|
|
99
|
+
const imageTarget =
|
|
100
|
+
target?.path && target.content === undefined && getImageMimeType(target.path) ? target.path : null;
|
|
101
|
+
const isImage = imageTarget !== null;
|
|
102
|
+
const imageUrl = imageTarget ? `${buildFsRawUrl({ ...target, path: imageTarget })}&v=${revision}` : null;
|
|
103
|
+
|
|
89
104
|
// Keys whose read has been requested or served, so a re-render cannot fire a
|
|
90
105
|
// duplicate fetch while the first one is still in flight.
|
|
91
106
|
const loadedRef = useRef<Set<string>>(new Set());
|
|
@@ -142,6 +157,15 @@ export function useFileEditor(
|
|
|
142
157
|
if (loadedRef.current.has(fileKey)) return;
|
|
143
158
|
loadedRef.current.add(fileKey);
|
|
144
159
|
|
|
160
|
+
// An image has no text to read: the surface points an <img> at the raw
|
|
161
|
+
// bytes. Fetching here would only decode them into replacement characters
|
|
162
|
+
// and paint mojibake, so the key is marked loaded and left bufferless.
|
|
163
|
+
if (isImage) {
|
|
164
|
+
setLoadError(null);
|
|
165
|
+
setIsLoading(false);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
if (target.content !== undefined) {
|
|
146
170
|
remember(fileKey, target.content);
|
|
147
171
|
setLoadError(null);
|
|
@@ -207,7 +231,7 @@ export function useFileEditor(
|
|
|
207
231
|
return () => {
|
|
208
232
|
cancelled = true;
|
|
209
233
|
};
|
|
210
|
-
}, [key, target, fallback, reportLoadError, remember, revision]);
|
|
234
|
+
}, [key, target, fallback, reportLoadError, remember, revision, isImage]);
|
|
211
235
|
|
|
212
236
|
useEffect(() => {
|
|
213
237
|
if (saveStatus !== 'saved') return;
|
|
@@ -217,7 +241,7 @@ export function useFileEditor(
|
|
|
217
241
|
|
|
218
242
|
const onChange = useCallback(
|
|
219
243
|
(next: string) => {
|
|
220
|
-
if (!target) return;
|
|
244
|
+
if (!target || isImage) return;
|
|
221
245
|
const fileKey = targetKey(target);
|
|
222
246
|
setContents((prev) => ({ ...prev, [fileKey]: next }));
|
|
223
247
|
if (!target.path) return;
|
|
@@ -228,11 +252,11 @@ export function useFileEditor(
|
|
|
228
252
|
}, 800);
|
|
229
253
|
pendingRef.current = { timer, file: target, content: next };
|
|
230
254
|
},
|
|
231
|
-
[target],
|
|
255
|
+
[target, isImage],
|
|
232
256
|
);
|
|
233
257
|
|
|
234
258
|
const saveNow = useCallback(() => {
|
|
235
|
-
if (!target) return;
|
|
259
|
+
if (!target || isImage) return;
|
|
236
260
|
const text = contents[targetKey(target)];
|
|
237
261
|
if (text === undefined) return;
|
|
238
262
|
if (pendingRef.current) {
|
|
@@ -240,29 +264,14 @@ export function useFileEditor(
|
|
|
240
264
|
pendingRef.current = null;
|
|
241
265
|
}
|
|
242
266
|
void persistRef.current(target, text);
|
|
243
|
-
}, [target, contents]);
|
|
244
|
-
|
|
245
|
-
const copy = useCallback(() => {
|
|
246
|
-
if (!target || !navigator.clipboard) return;
|
|
247
|
-
navigator.clipboard
|
|
248
|
-
.writeText(contents[targetKey(target)] ?? '')
|
|
249
|
-
.then(() => {
|
|
250
|
-
setCopied(true);
|
|
251
|
-
window.setTimeout(() => setCopied(false), 1500);
|
|
252
|
-
})
|
|
253
|
-
.catch(() => {});
|
|
254
|
-
}, [target, contents]);
|
|
267
|
+
}, [target, contents, isImage]);
|
|
255
268
|
|
|
256
|
-
const download =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
a.download = target.name;
|
|
263
|
-
a.click();
|
|
264
|
-
URL.revokeObjectURL(url);
|
|
265
|
-
}, [target, contents, downloadMimeType]);
|
|
269
|
+
const { copied, copy, download } = useFileTransfer({
|
|
270
|
+
target,
|
|
271
|
+
content,
|
|
272
|
+
imageUrl,
|
|
273
|
+
downloadMimeType,
|
|
274
|
+
});
|
|
266
275
|
|
|
267
276
|
const reset = useCallback(() => {
|
|
268
277
|
// Invalidate every key, then let the read effect refetch the active one.
|
|
@@ -309,6 +318,8 @@ export function useFileEditor(
|
|
|
309
318
|
copied,
|
|
310
319
|
isDirty,
|
|
311
320
|
language: target ? getLanguageFromPath(target.name) : 'javascript',
|
|
321
|
+
isImage,
|
|
322
|
+
imageUrl,
|
|
312
323
|
saveNow,
|
|
313
324
|
copy,
|
|
314
325
|
download,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Getting bytes *out* of an editor tab: copy to the clipboard and download.
|
|
8
|
+
*
|
|
9
|
+
* Split from `useFileEditor` because the two paths genuinely differ by kind —
|
|
10
|
+
* text is copied from the in-memory buffer and written to a Blob, an image has
|
|
11
|
+
* no buffer at all and is fetched at the moment of the action — and because
|
|
12
|
+
* that difference was pushing the editor hook past the file ceiling.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useState } from 'preact/hooks';
|
|
16
|
+
import { copyImageToClipboard, downloadUrl, saveBlob } from '@/client/hooks/editor/file-actions';
|
|
17
|
+
|
|
18
|
+
interface FileTransferTarget {
|
|
19
|
+
name: string;
|
|
20
|
+
path?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UseFileTransferOptions {
|
|
24
|
+
/** The active file, or null when the surface has no tab. */
|
|
25
|
+
target: FileTransferTarget | null;
|
|
26
|
+
/** Current text buffer for `target`; ignored for images. */
|
|
27
|
+
content: string;
|
|
28
|
+
/** Raw-byte URL of the active image, else null. */
|
|
29
|
+
imageUrl: string | null;
|
|
30
|
+
/** MIME type used for the text Blob. */
|
|
31
|
+
downloadMimeType: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UseFileTransferResult {
|
|
35
|
+
/** True for the 1.5s after a successful copy, so the button can confirm it. */
|
|
36
|
+
copied: boolean;
|
|
37
|
+
copy: () => void;
|
|
38
|
+
download: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useFileTransfer({
|
|
42
|
+
target,
|
|
43
|
+
content,
|
|
44
|
+
imageUrl,
|
|
45
|
+
downloadMimeType,
|
|
46
|
+
}: UseFileTransferOptions): UseFileTransferResult {
|
|
47
|
+
const [copied, setCopied] = useState(false);
|
|
48
|
+
|
|
49
|
+
/** Confirmation flash shared by both copy paths. */
|
|
50
|
+
const flashCopied = useCallback(() => {
|
|
51
|
+
setCopied(true);
|
|
52
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const copy = useCallback(() => {
|
|
56
|
+
if (!target) return;
|
|
57
|
+
// An image goes to the clipboard as a picture; a text file as its text.
|
|
58
|
+
if (imageUrl) {
|
|
59
|
+
void copyImageToClipboard(imageUrl).then(flashCopied).catch(() => {});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!navigator.clipboard) return;
|
|
63
|
+
navigator.clipboard.writeText(content).then(flashCopied).catch(() => {});
|
|
64
|
+
}, [target, content, imageUrl, flashCopied]);
|
|
65
|
+
|
|
66
|
+
const download = useCallback(() => {
|
|
67
|
+
if (!target) return;
|
|
68
|
+
// An image's bytes are fetched at download time, so a large picture costs
|
|
69
|
+
// nothing while its tab merely sits open.
|
|
70
|
+
if (imageUrl) {
|
|
71
|
+
void downloadUrl(imageUrl, target.name).catch(() => {});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
saveBlob(new Blob([content], { type: downloadMimeType }), target.name);
|
|
75
|
+
}, [target, content, downloadMimeType, imageUrl]);
|
|
76
|
+
|
|
77
|
+
return { copied, copy, download };
|
|
78
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import type { RefObject } from 'preact';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Measure an element's content width and keep it current.
|
|
6
|
+
*
|
|
7
|
+
* Panel widths are stored as a share of the group's available area, so the
|
|
8
|
+
* group has to report that area before any fraction can become pixels. Returns
|
|
9
|
+
* `null` until the first measurement — callers must treat that as "unknown",
|
|
10
|
+
* not as zero, or a panel would collapse on its first render.
|
|
11
|
+
*
|
|
12
|
+
* `ResizeObserver` is not optional here: the width changes on a window resize,
|
|
13
|
+
* on a devtools dock, and on a sibling panel being toggled, and none of those
|
|
14
|
+
* are `window` events.
|
|
15
|
+
*/
|
|
16
|
+
export function useAvailableWidth(ref: RefObject<HTMLElement | null>): number | null {
|
|
17
|
+
const [available, setAvailable] = useState<number | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const element = ref.current;
|
|
21
|
+
if (!element || typeof ResizeObserver === 'undefined') return;
|
|
22
|
+
|
|
23
|
+
const observer = new ResizeObserver(() => {
|
|
24
|
+
setAvailable(element.clientWidth || null);
|
|
25
|
+
});
|
|
26
|
+
observer.observe(element);
|
|
27
|
+
setAvailable(element.clientWidth || null);
|
|
28
|
+
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}, [ref]);
|
|
31
|
+
|
|
32
|
+
return available;
|
|
33
|
+
}
|
|
@@ -1,59 +1,56 @@
|
|
|
1
|
-
import { useCallback,
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
import { useCallback, useMemo } from 'preact/hooks';
|
|
2
|
+
import {
|
|
3
|
+
mergePanelWidths,
|
|
4
|
+
normalizePanelWidths,
|
|
5
|
+
type PanelWidths,
|
|
6
|
+
} from '@/shared/lib/workspace/panel-widths';
|
|
4
7
|
import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
|
|
5
|
-
import {
|
|
8
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
6
9
|
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
10
|
+
/** Session-state key holding this session's own per-panel widths. */
|
|
11
|
+
const PANEL_WIDTHS_KEY = 'layout.panelWidths';
|
|
9
12
|
|
|
10
13
|
interface PanelWidthsResult {
|
|
11
14
|
widths: PanelWidths;
|
|
12
|
-
/**
|
|
13
|
-
widthsRef: RefObject<PanelWidths>;
|
|
14
|
-
/** Fold measured panel widths in and persist the merged map. */
|
|
15
|
+
/** Fold measured panel widths into this session's map. */
|
|
15
16
|
commitWidths: (patch: PanelWidths) => void;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* Owns the desktop layout's per-panel width map
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Owns the desktop layout's per-panel width map.
|
|
21
|
+
*
|
|
22
|
+
* Widths are **per session**: they live in `session_ui_state` under
|
|
23
|
+
* `layout.panelWidths`, next to the rest of a session's layout state
|
|
24
|
+
* (`layout.activeRightPanel`, `layout.showRightPanel`, `layout.openedFiles`), so
|
|
25
|
+
* switching sessions brings back the layout that session was left in.
|
|
26
|
+
*
|
|
27
|
+
* `app_settings.desktopLayoutSizes` is the **seed**: a session that has never
|
|
28
|
+
* been resized reads it, which is what keeps an existing user's layout across
|
|
29
|
+
* the upgrade instead of resetting everyone to the defaults. Once a panel is
|
|
30
|
+
* dragged, the session stores its own map and the seed is no longer consulted
|
|
31
|
+
* for that session.
|
|
32
|
+
*
|
|
33
|
+
* There is deliberately no px→fraction rewrite pass here. Converting a legacy
|
|
34
|
+
* pixel value is done when a width is *read* (`resolvePanelWidth`), because a
|
|
35
|
+
* write would have to run before this session's own blob has loaded and would
|
|
36
|
+
* therefore overwrite it with the seed.
|
|
23
37
|
*/
|
|
24
38
|
export function usePanelWidths(
|
|
25
39
|
appSettings: Record<string, any>,
|
|
26
40
|
legacyView: RightPanelType,
|
|
27
41
|
): PanelWidthsResult {
|
|
28
|
-
const
|
|
29
|
-
normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
|
|
42
|
+
const seed = useMemo(
|
|
43
|
+
() => normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
|
|
44
|
+
[appSettings.desktopLayoutSizes, legacyView],
|
|
30
45
|
);
|
|
31
|
-
const
|
|
32
|
-
// Set while a debounced write is pending, which is also the flush condition.
|
|
33
|
-
const timerRef = useRef<number | undefined>(undefined);
|
|
34
|
-
|
|
35
|
-
const saveNow = useCallback(() => {
|
|
36
|
-
clearTimeout(timerRef.current);
|
|
37
|
-
timerRef.current = undefined;
|
|
38
|
-
writeSetting('desktopLayoutSizes', widthsRef.current);
|
|
39
|
-
}, []);
|
|
46
|
+
const [widths, setWidths] = useSessionState<PanelWidths>(PANEL_WIDTHS_KEY, seed);
|
|
40
47
|
|
|
41
48
|
const commitWidths = useCallback(
|
|
42
49
|
(patch: PanelWidths) => {
|
|
43
|
-
|
|
44
|
-
widthsRef.current = next;
|
|
45
|
-
setWidths(next);
|
|
46
|
-
clearTimeout(timerRef.current);
|
|
47
|
-
timerRef.current = window.setTimeout(saveNow, PERSIST_DEBOUNCE_MS);
|
|
50
|
+
setWidths((prev) => mergePanelWidths(prev, patch));
|
|
48
51
|
},
|
|
49
|
-
[
|
|
52
|
+
[setWidths],
|
|
50
53
|
);
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
// the user just dragged.
|
|
54
|
-
useEffect(() => () => {
|
|
55
|
-
if (timerRef.current !== undefined) saveNow();
|
|
56
|
-
}, [saveNow]);
|
|
57
|
-
|
|
58
|
-
return { widths, widthsRef, commitWidths };
|
|
55
|
+
return { widths, commitWidths };
|
|
59
56
|
}
|
package/src/client/tailwind.css
CHANGED
|
@@ -358,6 +358,28 @@
|
|
|
358
358
|
max-width: none !important;
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
/* Image viewer surface (`common/image-viewer/`): the stage is the pan surface,
|
|
362
|
+
the canvas carries the zoom/pan transform so the picture keeps its natural
|
|
363
|
+
pixel size and never reflows while it is being zoomed. The checkerboard sits
|
|
364
|
+
behind the canvas so a transparent PNG reads as transparent instead of
|
|
365
|
+
looking like a white file. */
|
|
366
|
+
.image-stage {
|
|
367
|
+
touch-action: none;
|
|
368
|
+
overscroll-behavior: contain;
|
|
369
|
+
background-color: var(--theme-canvas);
|
|
370
|
+
background-image:
|
|
371
|
+
linear-gradient(45deg, color-mix(in srgb, var(--theme-ink) 6%, transparent) 25%, transparent 25%),
|
|
372
|
+
linear-gradient(-45deg, color-mix(in srgb, var(--theme-ink) 6%, transparent) 25%, transparent 25%),
|
|
373
|
+
linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--theme-ink) 6%, transparent) 75%),
|
|
374
|
+
linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--theme-ink) 6%, transparent) 75%);
|
|
375
|
+
background-size: 16px 16px;
|
|
376
|
+
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
|
377
|
+
}
|
|
378
|
+
.image-canvas {
|
|
379
|
+
transform-origin: center center;
|
|
380
|
+
will-change: transform;
|
|
381
|
+
}
|
|
382
|
+
|
|
361
383
|
/* PWA Window Controls Overlay and Native Titlebar Styling */
|
|
362
384
|
.titlebar-drag-region {
|
|
363
385
|
-webkit-app-region: drag;
|