dsh-task-time-cost 0.0.0-stage → 0.2.8-beta.2
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 +29 -0
- package/COMPATIBILITY.md +26 -0
- package/CONTRIBUTING.md +11 -0
- package/LICENSE +21 -0
- package/README.en.md +60 -0
- package/README.md +59 -2
- package/THIRD_PARTY_NOTICES.md +113 -0
- package/cordis.patch.yml +4 -0
- package/data/baselines.json +12 -0
- package/data/defaults.json +21 -0
- package/data/models-dev-prices.json +1 -0
- package/data/prices.json +7 -0
- package/data/sources.json +15 -0
- package/docs/public/DETAIL.md +1 -0
- package/docs/public/DETAIL.zh.md +1 -0
- package/docs/public/acceptance.md +26 -0
- package/docs/public/guide.en.md +33 -0
- package/docs/public/guide.zh.md +35 -0
- package/docs/public/preview.svg +1 -0
- package/docs/public/privacy.md +13 -0
- package/docs/public/publishing.md +9 -0
- package/icon.svg +1 -0
- package/lib/client.js +175 -0
- package/lib/index.js +22948 -0
- package/lib/index.js.map +7 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +122 -4
- package/scripts/import-public-prices.mjs +35 -0
- package/src/client/INTEGRATION.md +61 -0
- package/src/client/config.ts +24 -0
- package/src/client/details.tsx +126 -0
- package/src/client/format.ts +218 -0
- package/src/client/index.tsx +225 -0
- package/src/client/locale.ts +353 -0
- package/src/client/management.tsx +246 -0
- package/src/client/placement.ts +26 -0
- package/src/client/remote.ts +59 -0
- package/src/client/store.ts +210 -0
- package/src/client/styles.ts +94 -0
- package/src/client/use-panel-placement.ts +54 -0
- package/src/client-entry.ts +12 -0
- package/src/host/config-entry.ts +47 -0
- package/src/host/config.ts +85 -0
- package/src/host/engine.ts +580 -0
- package/src/host/facts.ts +170 -0
- package/src/host/forecast-cost.ts +16 -0
- package/src/host/index.ts +269 -0
- package/src/host/record-schema.ts +175 -0
- package/src/host/repository.ts +167 -0
- package/src/host/runtime.ts +99 -0
- package/src/shared/ai-plan.ts +45 -0
- package/src/shared/baseline-selection.ts +43 -0
- package/src/shared/baselines.ts +135 -0
- package/src/shared/call-size.ts +32 -0
- package/src/shared/config.ts +65 -0
- package/src/shared/forecast.ts +91 -0
- package/src/shared/index.ts +12 -0
- package/src/shared/ledger.ts +38 -0
- package/src/shared/money.ts +46 -0
- package/src/shared/performance.ts +216 -0
- package/src/shared/pricing.ts +293 -0
- package/src/shared/public-prices.ts +57 -0
- package/src/shared/remote-contract.ts +72 -0
- package/src/shared/retained-time.ts +14 -0
- package/src/shared/time.ts +207 -0
- package/src/shared/types.ts +66 -0
- package/src/shared/usage.ts +232 -0
- package/src/shared/wire.ts +134 -0
- package/src/shared/workload.ts +265 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { EstimatorRemote, ManagementAction, ManagementView } from '../shared/wire.ts';
|
|
3
|
+
import { configFields, numericFields, parseConfiguration } from './config.ts';
|
|
4
|
+
import { formatDate, formatDuration, formatMoneyList, formatNumber, formatProfileMetrics, formatState } from './format.ts';
|
|
5
|
+
import { errorInfo, errorText, manageRemote, RemoteCallError } from './remote.ts';
|
|
6
|
+
import type { ClientError } from './remote.ts';
|
|
7
|
+
import type { MessageKey, Translate } from './locale.ts';
|
|
8
|
+
import type { SnapshotStore } from './store.ts';
|
|
9
|
+
|
|
10
|
+
const pendingLabels: Record<ManagementAction, MessageKey> = {
|
|
11
|
+
read: 'pendingRead', saveConfig: 'pendingSave', importBaselines: 'pendingImport', export: 'pendingExport', resetCalibration: 'pendingReset',
|
|
12
|
+
};
|
|
13
|
+
const successLabels: Record<ManagementAction, MessageKey> = {
|
|
14
|
+
read: 'reloaded', saveConfig: 'saved', importBaselines: 'imported', export: 'exported', resetCalibration: 'resetDone',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface Props { remote: EstimatorRemote; store: SnapshotStore; t: Translate; locale: string }
|
|
18
|
+
|
|
19
|
+
export function Management({ remote, store, t, locale }: Props) {
|
|
20
|
+
const id = React.useId();
|
|
21
|
+
const [data, setData] = React.useState<ManagementView | null>(null);
|
|
22
|
+
const [draft, setDraft] = React.useState('');
|
|
23
|
+
const [inputs, setInputs] = React.useState<Record<string, string>>({});
|
|
24
|
+
const [importText, setImportText] = React.useState('');
|
|
25
|
+
const [pending, setPending] = React.useState<ManagementAction | null>(null);
|
|
26
|
+
const [error, setError] = React.useState<ClientError | null>(null);
|
|
27
|
+
const [validation, setValidation] = React.useState('');
|
|
28
|
+
const [success, setSuccess] = React.useState<MessageKey | null>(null);
|
|
29
|
+
const [confirmReset, setConfirmReset] = React.useState(false);
|
|
30
|
+
const activeRequest = React.useRef<AbortController | null>(null);
|
|
31
|
+
const downloadAnchor = React.useRef<HTMLAnchorElement>(null);
|
|
32
|
+
const downloadUrl = React.useRef<string | null>(null);
|
|
33
|
+
const resetButton = React.useRef<HTMLButtonElement>(null);
|
|
34
|
+
const advancedSection = React.useRef<HTMLDetailsElement>(null);
|
|
35
|
+
const jsonSection = React.useRef<HTMLDetailsElement>(null);
|
|
36
|
+
const jsonInput = React.useRef<HTMLTextAreaElement>(null);
|
|
37
|
+
const importInput = React.useRef<HTMLTextAreaElement>(null);
|
|
38
|
+
const focusJson = () => {
|
|
39
|
+
if (advancedSection.current) advancedSection.current.open = true;
|
|
40
|
+
if (jsonSection.current) jsonSection.current.open = true;
|
|
41
|
+
jsonInput.current?.focus();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const adopt = React.useCallback((next: ManagementView) => {
|
|
45
|
+
setData(next);
|
|
46
|
+
setDraft(JSON.stringify(next.config, null, 2));
|
|
47
|
+
setInputs(numericFields(next.config));
|
|
48
|
+
store.configure(next.config);
|
|
49
|
+
}, [store]);
|
|
50
|
+
|
|
51
|
+
const run = React.useCallback(async (action: ManagementAction, payload = '', initial = false) => {
|
|
52
|
+
if (activeRequest.current) return;
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
activeRequest.current = controller;
|
|
55
|
+
setPending(action);
|
|
56
|
+
setError(null);
|
|
57
|
+
setValidation('');
|
|
58
|
+
setSuccess(null);
|
|
59
|
+
try {
|
|
60
|
+
const result = await manageRemote(remote, action, payload, controller.signal);
|
|
61
|
+
if (controller.signal.aborted) return;
|
|
62
|
+
if (action === 'export') {
|
|
63
|
+
if (result.exported === undefined) throw new RemoteCallError('EXPORT_EMPTY', '');
|
|
64
|
+
if (!downloadAnchor.current) throw new RemoteCallError('DOWNLOAD_UNAVAILABLE', '');
|
|
65
|
+
if (downloadUrl.current) URL.revokeObjectURL(downloadUrl.current);
|
|
66
|
+
downloadUrl.current = URL.createObjectURL(new Blob([result.exported], { type: 'application/json;charset=utf-8' }));
|
|
67
|
+
downloadAnchor.current.href = downloadUrl.current;
|
|
68
|
+
downloadAnchor.current.download = t('exportedFilename');
|
|
69
|
+
downloadAnchor.current.click();
|
|
70
|
+
}
|
|
71
|
+
if (result.data) adopt(result.data);
|
|
72
|
+
if (action === 'read' && !result.data) throw new RemoteCallError('MANAGEMENT_EMPTY', '');
|
|
73
|
+
if (action === 'saveConfig' || action === 'importBaselines' || action === 'resetCalibration') store.invalidate();
|
|
74
|
+
if (!initial) setSuccess(successLabels[action]);
|
|
75
|
+
if (action === 'resetCalibration') { setConfirmReset(false); resetButton.current?.focus(); }
|
|
76
|
+
} catch (failure) {
|
|
77
|
+
if (!controller.signal.aborted) setError(errorInfo(failure));
|
|
78
|
+
} finally {
|
|
79
|
+
if (activeRequest.current === controller) {
|
|
80
|
+
activeRequest.current = null;
|
|
81
|
+
if (!controller.signal.aborted) setPending(null);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}, [remote, store, adopt, t]);
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
void run('read', '', true);
|
|
88
|
+
return () => {
|
|
89
|
+
activeRequest.current?.abort();
|
|
90
|
+
activeRequest.current = null;
|
|
91
|
+
if (downloadUrl.current) URL.revokeObjectURL(downloadUrl.current);
|
|
92
|
+
downloadUrl.current = null;
|
|
93
|
+
};
|
|
94
|
+
}, [run]);
|
|
95
|
+
|
|
96
|
+
let parsed: Record<string, unknown> | null = null;
|
|
97
|
+
try { if (draft) parsed = parseConfiguration(draft); } catch { /* Validation is shown on save; the draft remains editable. */ }
|
|
98
|
+
|
|
99
|
+
// Calibration rows are ordered by their newest real observation, so a route
|
|
100
|
+
// that was never measured sinks below routes with local evidence.
|
|
101
|
+
const orderedProfiles = [...(data?.profiles ?? [])].sort((left, right) => (right.testedAt ?? 0) - (left.testedAt ?? 0));
|
|
102
|
+
const shownProfiles = orderedProfiles.slice(0, 8);
|
|
103
|
+
|
|
104
|
+
const changeField = (key: string, value: string) => {
|
|
105
|
+
setInputs((previous) => ({ ...previous, [key]: value }));
|
|
106
|
+
setSuccess(null);
|
|
107
|
+
setValidation('');
|
|
108
|
+
if (parsed && value.trim() && Number.isFinite(Number(value))) setDraft(JSON.stringify({ ...parsed, [key]: Number(value) }, null, 2));
|
|
109
|
+
};
|
|
110
|
+
const changeJson = (value: string) => {
|
|
111
|
+
setDraft(value);
|
|
112
|
+
setSuccess(null);
|
|
113
|
+
setValidation('');
|
|
114
|
+
try { setInputs(numericFields(parseConfiguration(value))); } catch { /* Keep the last valid form values while JSON is being typed. */ }
|
|
115
|
+
};
|
|
116
|
+
const save = (event: React.FormEvent<HTMLFormElement>) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
let next: Record<string, unknown>;
|
|
119
|
+
try { next = parseConfiguration(draft); }
|
|
120
|
+
catch (failure) {
|
|
121
|
+
setValidation(t('invalidConfig', { message: failure instanceof TypeError && failure.message === 'CONFIG_OBJECT_REQUIRED' ? t('objectRequired') : failure instanceof Error ? failure.message : t('objectRequired') }));
|
|
122
|
+
focusJson();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const field of configFields) {
|
|
126
|
+
const value = inputs[field.key] ?? '';
|
|
127
|
+
if (!value.trim() || !Number.isFinite(Number(value))) {
|
|
128
|
+
setValidation(t('fieldRequired', { field: t(field.label) }));
|
|
129
|
+
if (advancedSection.current) advancedSection.current.open = true;
|
|
130
|
+
const control = event.currentTarget.elements.namedItem(field.key);
|
|
131
|
+
if (control instanceof HTMLElement) control.focus();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
next[field.key] = Number(value);
|
|
135
|
+
}
|
|
136
|
+
void run('saveConfig', JSON.stringify(next));
|
|
137
|
+
};
|
|
138
|
+
const beginImport = () => {
|
|
139
|
+
if (!importText.trim()) { setValidation(t('importRequired')); importInput.current?.focus(); return; }
|
|
140
|
+
void run('importBaselines', importText);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return <section aria-label={t('settings')}>
|
|
144
|
+
<p className="dte-muted dte-small">{t('settingsHint')}</p>
|
|
145
|
+
<div className="dte-toolbar">
|
|
146
|
+
<button type="button" disabled={pending !== null} onClick={() => void run('read')}>{t('read')}</button>
|
|
147
|
+
{data && <span className="dte-muted dte-small">{t('profiles')}{t('listSeparator')}{formatNumber(data.profiles.length, locale, t)}</span>}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="dte-status" role="status" aria-live="polite">{pending ? t(pendingLabels[pending]) : success ? <span className="dte-success">{t(success)}</span> : null}</div>
|
|
150
|
+
{error && <p className="dte-error" role="alert">{errorText(error, t)}</p>}
|
|
151
|
+
{validation && <p className="dte-error" role="alert" id={`${id}-validation`}>{validation}</p>}
|
|
152
|
+
{data?.storage === 'unavailable' && <p className="dte-warn" role="status">{t('storageUnavailable')}</p>}
|
|
153
|
+
{data && <>
|
|
154
|
+
<form onSubmit={save} noValidate aria-label={t('config')} aria-describedby={validation ? `${id}-validation` : undefined}>
|
|
155
|
+
<details ref={advancedSection} className="dte-subsection">
|
|
156
|
+
<summary>{t('advancedSettings')}</summary>
|
|
157
|
+
<div className="dte-grid">
|
|
158
|
+
{configFields.map((field) => <label className="dte-field" key={field.key} htmlFor={`${id}-${field.key}`}>
|
|
159
|
+
<span>{t(field.label)}</span>
|
|
160
|
+
<input id={`${id}-${field.key}`} name={field.key} autoComplete="off" type="number" min={field.min} max={field.max} step={field.step}
|
|
161
|
+
value={inputs[field.key] ?? ''} disabled={pending !== null || !parsed} required
|
|
162
|
+
onChange={(event) => changeField(field.key, event.currentTarget.value)} />
|
|
163
|
+
</label>)}
|
|
164
|
+
</div>
|
|
165
|
+
<details ref={jsonSection} className="dte-subsection">
|
|
166
|
+
<summary>{t('advancedJson')}</summary>
|
|
167
|
+
<p className="dte-muted dte-small" id={`${id}-json-hint`}>{t('advancedHint')}</p>
|
|
168
|
+
<label className="dte-field" htmlFor={`${id}-json`}>
|
|
169
|
+
<span className="dte-small">{t('config')}</span>
|
|
170
|
+
<textarea ref={jsonInput} id={`${id}-json`} name="configuration" autoComplete="off" className="dte-code" rows={12} value={draft} spellCheck={false}
|
|
171
|
+
disabled={pending !== null} aria-describedby={`${id}-json-hint`} onChange={(event) => changeJson(event.currentTarget.value)} />
|
|
172
|
+
</label>
|
|
173
|
+
</details>
|
|
174
|
+
</details>
|
|
175
|
+
<div className="dte-actions"><button className="dte-primary" type="submit" disabled={pending !== null}>{t('save')}</button></div>
|
|
176
|
+
</form>
|
|
177
|
+
<details className="dte-subsection">
|
|
178
|
+
<summary>{t('performanceProfiles')}</summary>
|
|
179
|
+
<p className="dte-muted dte-small">{t('profilesHint')}</p>
|
|
180
|
+
{!orderedProfiles.length ? <p className="dte-muted">{t('noProfiles')}</p> : <ul className="dte-profiles">
|
|
181
|
+
{shownProfiles.map((profile) => {
|
|
182
|
+
const metrics = formatProfileMetrics(profile, locale, t);
|
|
183
|
+
const buckets = [profile.inputBucket, profile.cacheBucket, profile.concurrencyBucket].filter((value): value is string => !!value && value !== 'unknown');
|
|
184
|
+
// Historical unresolved buckets keep their recorded identity segment, so two
|
|
185
|
+
// rows of the same model stay distinguishable; resolved endpoints use a suffix.
|
|
186
|
+
const fingerprint = profile.endpointFingerprint;
|
|
187
|
+
const endpointBit = fingerprint && fingerprint !== 'unknown'
|
|
188
|
+
? fingerprint.startsWith('unresolved-') ? fingerprint.split('-')[1]?.slice(0, 6) ?? null : fingerprint.slice(-6)
|
|
189
|
+
: null;
|
|
190
|
+
const routeBits = [profile.providerId, profile.requestMode, profile.reasoningEffort,
|
|
191
|
+
profile.serviceTier && profile.serviceTier !== 'unknown' ? profile.serviceTier : null,
|
|
192
|
+
endpointBit, profile.mixedRoute ? t('mixedRouteShort') : null, ...buckets].filter((value): value is string => !!value);
|
|
193
|
+
return <li key={profile.key}>
|
|
194
|
+
<span>{profile.modelId}</span>
|
|
195
|
+
<span className="dte-muted dte-small">{routeBits.join(t('listSeparator'))}</span>
|
|
196
|
+
<span className="dte-small dte-tabular">{t('firstText')}{t('labelSeparator')}{metrics.latency}{t('listSeparator')}{t('visibleSpeed')}{t('labelSeparator')}{metrics.speed}</span>
|
|
197
|
+
<span className="dte-muted dte-small dte-tabular">{metrics.samples}{t('listSeparator')}{metrics.measured}{t('listSeparator')}{metrics.source}</span>
|
|
198
|
+
</li>;
|
|
199
|
+
})}
|
|
200
|
+
</ul>}
|
|
201
|
+
{orderedProfiles.length > shownProfiles.length && <p className="dte-muted dte-small">{t('profilesShown', { shown: formatNumber(shownProfiles.length, locale, t), total: formatNumber(orderedProfiles.length, locale, t) })}</p>}
|
|
202
|
+
</details>
|
|
203
|
+
<details className="dte-subsection">
|
|
204
|
+
<summary>{t('data')}</summary>
|
|
205
|
+
<p className="dte-muted dte-small" id={`${id}-import-hint`}>{t('importHint')}</p>
|
|
206
|
+
<label className="dte-field" htmlFor={`${id}-import`}>
|
|
207
|
+
<span>{t('importLabel')}</span>
|
|
208
|
+
<textarea ref={importInput} id={`${id}-import`} name="baselineImport" autoComplete="off" rows={5} value={importText} placeholder={t('importPlaceholder')}
|
|
209
|
+
disabled={pending !== null} spellCheck={false} aria-describedby={`${id}-import-hint`}
|
|
210
|
+
onChange={(event) => setImportText(event.currentTarget.value)} />
|
|
211
|
+
</label>
|
|
212
|
+
<div className="dte-actions">
|
|
213
|
+
<button type="button" disabled={pending !== null || !importText.trim()} onClick={beginImport}>{t('import')}</button>
|
|
214
|
+
<button type="button" disabled={pending !== null} onClick={() => void run('export')}>{t('export')}</button>
|
|
215
|
+
</div>
|
|
216
|
+
<div className="dte-actions">
|
|
217
|
+
<button ref={resetButton} type="button" className="dte-danger" disabled={pending !== null}
|
|
218
|
+
aria-expanded={confirmReset} aria-controls={`${id}-reset`} onClick={() => setConfirmReset((value) => !value)}>{t('reset')}</button>
|
|
219
|
+
</div>
|
|
220
|
+
{confirmReset && <div className="dte-confirm" id={`${id}-reset`} role="group" aria-label={t('reset')}>
|
|
221
|
+
<p>{t('resetWarning')}</p>
|
|
222
|
+
<div className="dte-actions">
|
|
223
|
+
<button type="button" className="dte-danger" disabled={pending !== null} onClick={() => void run('resetCalibration')}>{t('confirmReset')}</button>
|
|
224
|
+
<button type="button" disabled={pending !== null} onClick={() => { setConfirmReset(false); resetButton.current?.focus(); }}>{t('cancel')}</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>}
|
|
227
|
+
</details>
|
|
228
|
+
<details className="dte-subsection">
|
|
229
|
+
<summary>{t('history')}</summary>
|
|
230
|
+
{!data.history.length ? <p className="dte-muted">{t('noHistory')}</p> : <div className="dte-table-wrap"><table>
|
|
231
|
+
<thead><tr><th scope="col">{t('timestamp')}</th><th scope="col">{t('status')}</th><th scope="col">{t('duration')}</th><th scope="col">{t('knownCost')}</th></tr></thead>
|
|
232
|
+
<tbody>{data.history.slice(-20).reverse().map((item) => <tr key={item.taskKey}>
|
|
233
|
+
<td>{formatDate(item.startedAt, locale, t)}</td><td>{formatState(item.state, t)}</td>
|
|
234
|
+
<td>{formatDuration(item.elapsedActiveMs, t)}</td><td>{formatMoneyList(item.knownCost, locale, t)}</td>
|
|
235
|
+
</tr>)}</tbody>
|
|
236
|
+
</table></div>}
|
|
237
|
+
</details>
|
|
238
|
+
<details className="dte-subsection">
|
|
239
|
+
<summary>{t('diagnostics')}</summary>
|
|
240
|
+
{!data.diagnostics.length ? <p className="dte-muted">{t('noDiagnostics')}</p> : <ul>{data.diagnostics.slice(-20).reverse().map((item, index) =>
|
|
241
|
+
<li key={`${item.time}-${index}`}><span className="dte-muted">{formatDate(item.time, locale, t)}{t('listSeparator')}{item.code}</span><br />{t('rawDetail', { message: item.message })}</li>)}</ul>}
|
|
242
|
+
</details>
|
|
243
|
+
</>}
|
|
244
|
+
<a ref={downloadAnchor} hidden tabIndex={-1} aria-hidden="true" />
|
|
245
|
+
</section>;
|
|
246
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PanelAnchor { left: number; top: number; bottom: number }
|
|
2
|
+
export interface PanelSize { width: number; height: number }
|
|
3
|
+
export interface PanelViewport { width: number; height: number; topInset: number; inset?: number; gap?: number }
|
|
4
|
+
export interface PanelPlacement extends PanelSize { left: number; top: number; maxHeight: number }
|
|
5
|
+
|
|
6
|
+
/** Prefer the native above-trigger seat, flip when needed, then scroll within the larger safe area. */
|
|
7
|
+
export function placeStatPanel(anchor: PanelAnchor, size: PanelSize, viewport: PanelViewport): PanelPlacement {
|
|
8
|
+
const inset = viewport.inset ?? 12;
|
|
9
|
+
const gap = viewport.gap ?? 8;
|
|
10
|
+
const leftEdge = Math.min(inset, viewport.width / 2);
|
|
11
|
+
const rightEdge = Math.max(leftEdge, viewport.width - inset);
|
|
12
|
+
const bottomEdge = Math.max(0, viewport.height - inset);
|
|
13
|
+
const topEdge = Math.min(bottomEdge, Math.max(inset, viewport.topInset));
|
|
14
|
+
const width = Math.min(size.width, rightEdge - leftEdge);
|
|
15
|
+
const above = Math.max(0, Math.min(anchor.top - gap, bottomEdge) - topEdge);
|
|
16
|
+
const below = Math.max(0, bottomEdge - Math.max(anchor.bottom + gap, topEdge));
|
|
17
|
+
const useAbove = size.height <= above || size.height > below && above >= below;
|
|
18
|
+
const maxHeight = useAbove ? above : below;
|
|
19
|
+
const height = Math.min(size.height, maxHeight);
|
|
20
|
+
const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(value, high));
|
|
21
|
+
return {
|
|
22
|
+
left: clamp(anchor.left, leftEdge, rightEdge - width),
|
|
23
|
+
top: clamp(useAbove ? anchor.top - gap - height : anchor.bottom + gap, topEdge, bottomEdge - height),
|
|
24
|
+
width, height, maxHeight,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EstimatorRemote, ManagementAction, ManagementResult, RemoteResult } from '../shared/wire.ts';
|
|
2
|
+
import type { Translate } from './locale.ts';
|
|
3
|
+
|
|
4
|
+
export class RemoteCallError extends Error {
|
|
5
|
+
readonly code: string;
|
|
6
|
+
constructor(code: string, message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = 'RemoteCallError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export interface ClientError { code: string; message: string }
|
|
13
|
+
|
|
14
|
+
export function errorInfo(error: unknown): ClientError {
|
|
15
|
+
if (error instanceof RemoteCallError) return { code: error.code, message: error.message };
|
|
16
|
+
if (error instanceof Error) return { code: error.name || 'ERROR', message: error.message };
|
|
17
|
+
return { code: 'ERROR', message: typeof error === 'string' ? error : '' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function errorText(error: ClientError, t: Translate): string {
|
|
21
|
+
if (error.code === 'TIMEOUT') return t('requestTimeout');
|
|
22
|
+
if (error.code === 'ABORTED') return t('requestCancelled');
|
|
23
|
+
return t('errorDetail', { message: error.message || t('operationFailed'), code: error.code });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function unwrapRemote<T>(result: RemoteResult<T>): T {
|
|
27
|
+
if (!result.ok) throw new RemoteCallError(result.error.code, result.error.message);
|
|
28
|
+
return result.value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Propagate RPC refusals, thrown transport errors, cancellation, and a bounded timeout. */
|
|
32
|
+
export async function requestRemote<T>(call: (signal: AbortSignal) => Promise<RemoteResult<T>>, parent?: AbortSignal, timeoutMs = 15_000): Promise<T> {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
let rejectAbort: ((error: RemoteCallError) => void) | undefined;
|
|
36
|
+
const aborted = new Promise<never>((_, reject) => { rejectAbort = reject; });
|
|
37
|
+
const onAbort = () => {
|
|
38
|
+
controller.abort();
|
|
39
|
+
rejectAbort?.(new RemoteCallError('ABORTED', ''));
|
|
40
|
+
};
|
|
41
|
+
parent?.addEventListener('abort', onAbort, { once: true });
|
|
42
|
+
try {
|
|
43
|
+
if (parent?.aborted) throw new RemoteCallError('ABORTED', '');
|
|
44
|
+
if (timeoutMs > 0) timer = setTimeout(() => {
|
|
45
|
+
controller.abort();
|
|
46
|
+
rejectAbort?.(new RemoteCallError('TIMEOUT', ''));
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
return unwrapRemote(await Promise.race([call(controller.signal), aborted]));
|
|
49
|
+
} finally {
|
|
50
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
51
|
+
parent?.removeEventListener('abort', onAbort);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function manageRemote(remote: EstimatorRemote, action: ManagementAction, payload: string, signal?: AbortSignal): Promise<ManagementResult> {
|
|
56
|
+
const result = await requestRemote((abort) => remote.manage(action, payload, abort), signal);
|
|
57
|
+
if (!result.ok) throw new RemoteCallError('MANAGEMENT', result.message);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { EstimatorRemote, EstimatorView, RetainedTimeForecast } from '../shared/wire.ts';
|
|
2
|
+
import { isLive, isTerminal } from './format.ts';
|
|
3
|
+
import { errorInfo, requestRemote } from './remote.ts';
|
|
4
|
+
import type { ClientError } from './remote.ts';
|
|
5
|
+
|
|
6
|
+
export interface SnapshotState {
|
|
7
|
+
view: EstimatorView | null;
|
|
8
|
+
pending: boolean;
|
|
9
|
+
error: ClientError | null;
|
|
10
|
+
receivedAt: number;
|
|
11
|
+
lastForecast: RetainedTimeForecast | null;
|
|
12
|
+
}
|
|
13
|
+
interface Entry {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
turn: number | null;
|
|
16
|
+
state: SnapshotState;
|
|
17
|
+
listeners: Set<() => void>;
|
|
18
|
+
inFlight: Promise<void> | null;
|
|
19
|
+
controller: AbortController | null;
|
|
20
|
+
poll: ReturnType<typeof setTimeout> | null;
|
|
21
|
+
projectionRevision: number | undefined;
|
|
22
|
+
dirty: boolean;
|
|
23
|
+
generation: number;
|
|
24
|
+
fetched: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A single clock for every live row. It never calls the remote service. */
|
|
28
|
+
export class LocalClock {
|
|
29
|
+
private now = Date.now();
|
|
30
|
+
private listeners = new Set<() => void>();
|
|
31
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
32
|
+
private intervalMs = 1_000;
|
|
33
|
+
getSnapshot = () => this.now;
|
|
34
|
+
subscribe = (listener: () => void) => {
|
|
35
|
+
this.listeners.add(listener);
|
|
36
|
+
if (!this.timer) this.start();
|
|
37
|
+
return () => {
|
|
38
|
+
this.listeners.delete(listener);
|
|
39
|
+
if (!this.listeners.size) this.stop();
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
private start() {
|
|
43
|
+
this.now = Date.now();
|
|
44
|
+
this.timer = setInterval(() => {
|
|
45
|
+
this.now = Date.now();
|
|
46
|
+
for (const listener of this.listeners) listener();
|
|
47
|
+
}, this.intervalMs);
|
|
48
|
+
}
|
|
49
|
+
private stop() {
|
|
50
|
+
if (this.timer) clearInterval(this.timer);
|
|
51
|
+
this.timer = null;
|
|
52
|
+
}
|
|
53
|
+
configure(intervalMs: number) {
|
|
54
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
|
|
55
|
+
this.intervalMs = Math.max(250, intervalMs);
|
|
56
|
+
if (this.timer) { this.stop(); this.start(); }
|
|
57
|
+
}
|
|
58
|
+
dispose() { this.stop(); this.listeners.clear(); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Shared by both slots; each session/turn has at most one request and one polling timer. */
|
|
62
|
+
export class SnapshotStore {
|
|
63
|
+
readonly clock = new LocalClock();
|
|
64
|
+
private entries = new Map<string, Entry>();
|
|
65
|
+
private refreshMs = 5_000;
|
|
66
|
+
private disposed = false;
|
|
67
|
+
private readonly remote: EstimatorRemote;
|
|
68
|
+
constructor(remote: EstimatorRemote) { this.remote = remote; }
|
|
69
|
+
|
|
70
|
+
private entry(sessionId: string, turn: number | null): Entry {
|
|
71
|
+
const key = JSON.stringify([sessionId, turn]);
|
|
72
|
+
let entry = this.entries.get(key);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = {
|
|
75
|
+
sessionId, turn, state: { view: null, pending: false, error: null, receivedAt: 0, lastForecast: null },
|
|
76
|
+
listeners: new Set(), inFlight: null, controller: null, poll: null,
|
|
77
|
+
projectionRevision: undefined, dirty: false, generation: 0, fetched: false,
|
|
78
|
+
};
|
|
79
|
+
this.entries.set(key, entry);
|
|
80
|
+
}
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
getSnapshot = (sessionId: string, turn: number | null): SnapshotState => this.entry(sessionId, turn).state;
|
|
84
|
+
|
|
85
|
+
subscribe = (sessionId: string, turn: number | null, listener: () => void) => {
|
|
86
|
+
const entry = this.entry(sessionId, turn);
|
|
87
|
+
entry.listeners.add(listener);
|
|
88
|
+
if (!entry.fetched) void this.refresh(sessionId, turn);
|
|
89
|
+
else this.schedulePoll(entry);
|
|
90
|
+
return () => {
|
|
91
|
+
entry.listeners.delete(listener);
|
|
92
|
+
if (!entry.listeners.size) {
|
|
93
|
+
this.clearPoll(entry);
|
|
94
|
+
entry.dirty = false;
|
|
95
|
+
if (entry.inFlight) {
|
|
96
|
+
entry.generation++;
|
|
97
|
+
entry.controller?.abort();
|
|
98
|
+
entry.controller = null;
|
|
99
|
+
entry.inFlight = null;
|
|
100
|
+
entry.fetched = false;
|
|
101
|
+
entry.state = { ...entry.state, pending: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Event-driven refresh also catches a turn that starts and finishes between polling ticks. */
|
|
108
|
+
projectionChanged(sessionId: string, turn: number | null, revision: number | undefined) {
|
|
109
|
+
if (this.disposed || revision === undefined) return;
|
|
110
|
+
const entry = this.entry(sessionId, turn);
|
|
111
|
+
if (entry.projectionRevision === revision) return;
|
|
112
|
+
const first = entry.projectionRevision === undefined;
|
|
113
|
+
entry.projectionRevision = revision;
|
|
114
|
+
if (entry.inFlight) {
|
|
115
|
+
if (!first) entry.dirty = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
void this.refresh(sessionId, turn);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
refresh = (sessionId: string, turn: number | null): Promise<void> => {
|
|
122
|
+
if (this.disposed) return Promise.resolve();
|
|
123
|
+
const entry = this.entry(sessionId, turn);
|
|
124
|
+
if (entry.inFlight) return entry.inFlight;
|
|
125
|
+
this.clearPoll(entry);
|
|
126
|
+
const generation = ++entry.generation;
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
entry.controller = controller;
|
|
129
|
+
entry.dirty = false;
|
|
130
|
+
entry.state = { ...entry.state, pending: true, error: null };
|
|
131
|
+
this.emit(entry);
|
|
132
|
+
entry.inFlight = (async () => {
|
|
133
|
+
try {
|
|
134
|
+
const view = await requestRemote((signal) => this.remote.snapshot(sessionId, turn, signal), controller.signal);
|
|
135
|
+
if (this.disposed || generation !== entry.generation) return;
|
|
136
|
+
entry.fetched = true;
|
|
137
|
+
const snapshot = view.snapshot;
|
|
138
|
+
const previous = snapshot ? [view.lastForecast, entry.state.lastForecast]
|
|
139
|
+
.filter((value): value is RetainedTimeForecast => !!value && value.taskKey === snapshot.taskKey)
|
|
140
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null : entry.state.lastForecast;
|
|
141
|
+
const lastForecast = snapshot && isTerminal(snapshot.state) ? null
|
|
142
|
+
: snapshot?.remainingMs && snapshot.remainingMs.high > 0 ? { taskKey: snapshot.taskKey,
|
|
143
|
+
remainingMs: { ...snapshot.remainingMs }, estimatedTotalMs: snapshot.estimatedTotalMs ? { ...snapshot.estimatedTotalMs } : null, updatedAt: snapshot.updatedAt }
|
|
144
|
+
: previous;
|
|
145
|
+
entry.state = {
|
|
146
|
+
view, pending: false, receivedAt: Date.now(), lastForecast,
|
|
147
|
+
error: view.error ? { code: 'ESTIMATE', message: view.error } : null,
|
|
148
|
+
};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (this.disposed || generation !== entry.generation) return;
|
|
151
|
+
entry.fetched = true;
|
|
152
|
+
entry.state = { ...entry.state, pending: false, error: errorInfo(error) };
|
|
153
|
+
} finally {
|
|
154
|
+
if (generation === entry.generation && !this.disposed) {
|
|
155
|
+
entry.inFlight = null;
|
|
156
|
+
entry.controller = null;
|
|
157
|
+
this.emit(entry);
|
|
158
|
+
if (entry.dirty && entry.listeners.size) void this.refresh(sessionId, turn);
|
|
159
|
+
else this.schedulePoll(entry);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
return entry.inFlight;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
configure(config: Record<string, unknown>) {
|
|
167
|
+
const refresh = config.predictionRefreshMs;
|
|
168
|
+
if (typeof refresh === 'number' && Number.isFinite(refresh) && refresh > 0) this.refreshMs = Math.max(250, refresh);
|
|
169
|
+
const timer = config.timerRefreshMs;
|
|
170
|
+
if (typeof timer === 'number') this.clock.configure(timer);
|
|
171
|
+
for (const entry of this.entries.values()) {
|
|
172
|
+
this.clearPoll(entry);
|
|
173
|
+
this.schedulePoll(entry);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
invalidate() {
|
|
178
|
+
for (const entry of this.entries.values()) {
|
|
179
|
+
if (!entry.listeners.size) { entry.fetched = false; continue; }
|
|
180
|
+
if (entry.inFlight) entry.dirty = true;
|
|
181
|
+
else void this.refresh(entry.sessionId, entry.turn);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private emit(entry: Entry) { for (const listener of entry.listeners) listener(); }
|
|
186
|
+
private clearPoll(entry: Entry) {
|
|
187
|
+
if (entry.poll !== null) clearTimeout(entry.poll);
|
|
188
|
+
entry.poll = null;
|
|
189
|
+
}
|
|
190
|
+
private schedulePoll(entry: Entry) {
|
|
191
|
+
if (this.disposed || entry.poll || entry.inFlight || !entry.listeners.size) return;
|
|
192
|
+
const snapshot = entry.state.view?.snapshot;
|
|
193
|
+
if (!snapshot || !isLive(snapshot.state)) return;
|
|
194
|
+
entry.poll = setTimeout(() => {
|
|
195
|
+
entry.poll = null;
|
|
196
|
+
void this.refresh(entry.sessionId, entry.turn);
|
|
197
|
+
}, this.refreshMs);
|
|
198
|
+
}
|
|
199
|
+
dispose() {
|
|
200
|
+
this.disposed = true;
|
|
201
|
+
for (const entry of this.entries.values()) {
|
|
202
|
+
this.clearPoll(entry);
|
|
203
|
+
entry.generation++;
|
|
204
|
+
entry.controller?.abort();
|
|
205
|
+
entry.listeners.clear();
|
|
206
|
+
}
|
|
207
|
+
this.entries.clear();
|
|
208
|
+
this.clock.dispose();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export const styles = `
|
|
2
|
+
.dte{display:inline-flex;flex:0 1 auto;max-width:100%;color:var(--dsw-alias-label-secondary);font:inherit;line-height:inherit;min-width:0;box-sizing:border-box}
|
|
3
|
+
.dte *{box-sizing:border-box}
|
|
4
|
+
.dte button,.dte input,.dte textarea,.dte select{font:inherit;color:inherit}
|
|
5
|
+
.dte button,.dte summary{cursor:pointer}
|
|
6
|
+
.dte button{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:6px;min-height:32px;padding:4px 10px;transition:background-color .15s,border-color .15s}
|
|
7
|
+
.dte button:hover:not(:disabled),.dte summary:hover{background:var(--dsw-alias-bg-layer-2)}
|
|
8
|
+
.dte button:disabled{cursor:default;opacity:.6}
|
|
9
|
+
.dte button:focus-visible,.dte summary:focus-visible,.dte input:focus-visible,.dte textarea:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
10
|
+
.dte button.dte-primary{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}
|
|
11
|
+
.dte button.dte-danger{color:var(--dsw-alias-state-error-primary)}
|
|
12
|
+
.dte .dte-quiet{border-color:transparent;background:transparent}
|
|
13
|
+
.dte .dte-muted{color:var(--dsw-alias-label-secondary)}
|
|
14
|
+
.dte .dte-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere}
|
|
15
|
+
.dte .dte-warn{color:var(--dsw-alias-state-warn-primary)}
|
|
16
|
+
.dte .dte-success{color:var(--dsw-alias-state-success-primary)}
|
|
17
|
+
.dte .dte-small{font-size:.9em}
|
|
18
|
+
.dte .dte-tabular{font-variant-numeric:tabular-nums}
|
|
19
|
+
.dte details>summary{list-style:none}
|
|
20
|
+
.dte details>summary::-webkit-details-marker{display:none}
|
|
21
|
+
.dte .dte-main{display:flex;align-items:center;gap:8px;width:100%;min-height:32px;padding:4px 6px;border-radius:5px;flex-wrap:wrap;overflow-wrap:anywhere;text-align:left}
|
|
22
|
+
.dte .dte-main .dte-chevron{width:12px;height:12px;flex:none;transition:transform .15s}
|
|
23
|
+
.dte details[open]>.dte-main .dte-chevron{transform:rotate(90deg)}
|
|
24
|
+
.dte .dte-main .dte-main-text{display:flex;gap:4px 12px;flex:1;flex-wrap:wrap;min-width:0;align-items:baseline}
|
|
25
|
+
.dte .dte-main .dte-value{font-weight:500}
|
|
26
|
+
.dte .dte-state{display:inline-flex;align-items:center;gap:5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
27
|
+
.dte .dte-state::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--dsw-alias-state-idle-primary);flex:none}
|
|
28
|
+
.dte .dte-state[data-state=running]::before{background:var(--dsw-alias-brand-primary)}
|
|
29
|
+
.dte .dte-state[data-state=completed]::before{background:var(--dsw-alias-state-success-primary)}
|
|
30
|
+
.dte .dte-state[data-state=failed]::before{background:var(--dsw-alias-state-error-primary)}
|
|
31
|
+
.dte .dte-panel{margin:4px 0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-base);max-height:min(60vh,38rem);overflow:auto;overscroll-behavior:contain;padding:12px;scrollbar-gutter:stable}
|
|
32
|
+
.dte .dte-toolbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
|
|
33
|
+
/* Any spacer pushes what follows to the far edge; the header needs it too, or
|
|
34
|
+
the close button sits next to the title instead of at the panel's right. */
|
|
35
|
+
.dte .dte-spacer{flex:1}
|
|
36
|
+
.dte .dte-subsection{border-top:1px solid var(--dsw-alias-border-l1);margin-top:12px;padding-top:10px}
|
|
37
|
+
.dte .dte-subsection>summary{padding:3px 0;min-height:30px;display:flex;align-items:center;gap:6px;font-weight:500}
|
|
38
|
+
.dte .dte-subsection>summary::before{content:'›';display:inline-block;font-weight:400;transition:transform .15s}
|
|
39
|
+
.dte .dte-subsection[open]>summary::before{transform:rotate(90deg)}
|
|
40
|
+
.dte h3{font-size:inherit;margin:0 0 8px;font-weight:600}
|
|
41
|
+
.dte p{margin:6px 0;overflow-wrap:anywhere}
|
|
42
|
+
.dte dl.dte-facts{display:grid;grid-template-columns:minmax(7rem,1fr) minmax(0,2fr);gap:5px 12px;margin:8px 0}
|
|
43
|
+
.dte .dte-facts dt{color:var(--dsw-alias-label-secondary)}
|
|
44
|
+
.dte .dte-facts dd{margin:0;overflow-wrap:anywhere;min-width:0;font-variant-numeric:tabular-nums}
|
|
45
|
+
.dte .dte-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px}
|
|
46
|
+
.dte .dte-field{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
47
|
+
.dte input,.dte textarea{width:100%;min-width:0;min-height:32px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:5px;padding:5px 7px}
|
|
48
|
+
.dte textarea{resize:vertical;line-height:1.5;min-height:112px;tab-size:2}
|
|
49
|
+
.dte .dte-code{font-family:inherit;font-variant-numeric:tabular-nums;white-space:pre;overflow:auto}
|
|
50
|
+
.dte .dte-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:10px}
|
|
51
|
+
.dte .dte-confirm{border:1px solid var(--dsw-alias-state-warn-primary);border-radius:5px;padding:9px;margin-top:8px}
|
|
52
|
+
.dte ul{padding-inline-start:20px;margin:6px 0}
|
|
53
|
+
.dte li{overflow-wrap:anywhere;margin:4px 0}
|
|
54
|
+
.dte .dte-profiles{list-style:none;padding:0;margin:8px 0;display:flex;flex-direction:column;gap:8px}
|
|
55
|
+
.dte .dte-profiles>li{display:flex;flex-direction:column;gap:1px;min-width:0;margin:0;padding:0;overflow-wrap:anywhere}
|
|
56
|
+
.dte .dte-table-wrap{overflow:auto;max-width:100%}
|
|
57
|
+
.dte table{width:100%;border-collapse:collapse;text-align:start;font-size:inherit}
|
|
58
|
+
.dte th,.dte td{padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);vertical-align:top;overflow-wrap:anywhere}
|
|
59
|
+
.dte th{font-weight:500;color:var(--dsw-alias-label-secondary);text-align:start}
|
|
60
|
+
.dte .dte-status{min-height:1.5em;margin:6px 0}
|
|
61
|
+
.dte a{color:var(--dsw-alias-brand-primary);overflow-wrap:anywhere}
|
|
62
|
+
/* Native StatsPills / TurnUsagePanel geometry (MIT), without private imports.
|
|
63
|
+
Both roots shrink-wrap; neither claims the composer's available width. */
|
|
64
|
+
.dte[data-slot=composer-dock]{font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px))}
|
|
65
|
+
.dte button.dte-line{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:100%;min-height:0;padding:1px 8px;background:transparent;border:0;border-radius:var(--dsw-radius-sm);text-align:left;color:var(--dsw-alias-label-tertiary);font:inherit;font-variant-numeric:tabular-nums;line-height:inherit;white-space:nowrap;transition:none}
|
|
66
|
+
.dte button.dte-line[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
67
|
+
@media(hover:hover){.dte button.dte-line:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}}
|
|
68
|
+
.dte .dte-line-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
69
|
+
.dte button.dte-line-tail{gap:4px;height:calc(28px + var(--dsh-content-font-delta,0px));padding:6px 8px;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);line-height:calc(24px + var(--dsh-content-font-delta,0px))}
|
|
70
|
+
.dte button.dte-line-tail[aria-expanded=true],.dte button.dte-line-tail:hover:not(:disabled){color:var(--dsw-alias-label-tertiary)}
|
|
71
|
+
.dte .dte-icon{flex:none;width:14px;height:14px;color:inherit;overflow:visible}
|
|
72
|
+
.dte .dte-line-tail .dte-icon{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}
|
|
73
|
+
.dte .dte-icon-ring{display:none}
|
|
74
|
+
.dte .dte-chip{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);font-size:.85em;white-space:nowrap;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:0 8px;line-height:1.7}
|
|
75
|
+
/* Native stat-dialog surface (MIT). Owned-element measurements place the
|
|
76
|
+
body portal with the native gap/inset and a bounded internal scroll area. */
|
|
77
|
+
.dte .dte-pop{position:fixed;inset:auto;z-index:1100;box-sizing:border-box;width:max-content;min-width:min(300px,calc(100vw - 24px));max-width:min(440px,calc(100vw - 24px));padding:16px;border:0;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);font-family:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);cursor:default;max-height:calc(100dvh - 12px - max(12px,var(--dsh-frame-overlay-top,12px)));overflow-x:hidden;overflow-y:auto;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);top:max(12px,var(--dsh-frame-overlay-top,12px));left:12px;height:auto;min-height:0;margin:0;overscroll-behavior:contain}
|
|
78
|
+
.dte .dte-pop-head{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px;color:var(--dsw-alias-label-primary);font-weight:500}
|
|
79
|
+
.dte .dte-pop-title{display:inline-flex;align-items:center;gap:6px;min-width:0}
|
|
80
|
+
.dte .dte-pop-rule{margin-bottom:10px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
|
81
|
+
.dte .dte-summary{display:grid;grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;color:var(--dsw-alias-label-tertiary)}
|
|
82
|
+
.dte .dte-summary dt,.dte .dte-summary dd{min-width:0;margin:0}
|
|
83
|
+
.dte .dte-summary dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}
|
|
84
|
+
@media(max-width:480px){.dte button.dte-line-tail{justify-content:center;width:calc(28px + var(--dsh-content-font-delta,0px));padding:6px}.dte .dte-line-tail .dte-line-text{display:none}}
|
|
85
|
+
.dte .dte-pop-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:12px 0 2px;padding-top:8px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
|
86
|
+
.dte button.dte-link{border:0;background:transparent;padding:2px 0;min-height:24px;color:var(--dsw-alias-label-secondary)}
|
|
87
|
+
.dte button.dte-link-active{color:var(--dsw-alias-brand-primary)}
|
|
88
|
+
@keyframes dte-arc{to{transform:rotate(360deg)}}
|
|
89
|
+
@keyframes dte-pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
|
90
|
+
@media(max-width:520px){.dte .dte-grid{grid-template-columns:1fr}.dte .dte-panel{padding:10px}.dte dl.dte-facts{grid-template-columns:minmax(6rem,1fr) minmax(0,1.5fr)}}
|
|
91
|
+
@media(pointer:coarse){.dte button,.dte summary,.dte input{min-height:44px}.dte textarea{font-size:max(1em,16px)}}
|
|
92
|
+
/* Reduced motion must also stop the looping state icon, not only transitions. */
|
|
93
|
+
@media(prefers-reduced-motion:reduce){.dte *{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
|
|
94
|
+
`;
|