@ash-qw/dsh-theme-prts 2.5.3 → 2.5.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "2.5.3",
3
+ "version": "2.5.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web and the official desktop client",
@@ -5,7 +5,16 @@ export function createBackgroundLayout({window,document}) {
5
5
  const raf=window.requestAnimationFrame?.bind(window) ?? (fn=>window.setTimeout(fn,16))
6
6
  const caf=window.cancelAnimationFrame?.bind(window) ?? window.clearTimeout.bind(window)
7
7
  let state={}, frame, disposed=false, enabled=false, observing=false, hostFrame, operation, sessions
8
- const set=(name,value)=>{if(root.style.getPropertyValue(name)!==value) root.style.setProperty(name,value)}
8
+ const sidebarTargets = new Set()
9
+ const set=(name,value)=>{
10
+ const targets=name.startsWith('--prts-protected-sidebar-')?[sessions,document.querySelector('[data-prts-nav-rail]')]:[root]
11
+ for(const node of targets){
12
+ if(!node)continue
13
+ if(node!==root)sidebarTargets.add(node)
14
+ if(value===null)node.style.removeProperty(name)
15
+ else if(node.style.getPropertyValue(name)!==value)node.style.setProperty(name,value)
16
+ }
17
+ }
9
18
  const update=()=>{
10
19
  frame=undefined
11
20
  if(disposed||!observing)return
@@ -17,6 +26,8 @@ export function createBackgroundLayout({window,document}) {
17
26
  for(const node of [hostFrame||operation,sessions])if(node)resize?.observe(node)
18
27
  }
19
28
  const railWidth=parseFloat(window.getComputedStyle(root).getPropertyValue('--prts-nav-width'))||0
29
+ const writes=[]
30
+ const write=(...args)=>writes.push(args)
20
31
  for(const slot of ['full','conversation','sidebar']){
21
32
  const entry=state[slot]
22
33
  const node=slot==='sidebar'?sessions:slot==='conversation'?(hostFrame||operation):operation
@@ -25,15 +36,16 @@ export function createBackgroundLayout({window,document}) {
25
36
  const active=entry?.region && width>0 && height>0
26
37
  if(active){
27
38
  const p=backgroundPlacement(entry.width,entry.height,width,height,entry.region)
28
- set(`--prts-protected-${slot}-x`,`${p.x}px`)
29
- set(`--prts-protected-${slot}-y`,`${p.y}px`)
30
- if(slot==='sidebar')set('--prts-protected-sidebar-width',`${p.width}px`)
39
+ write(`--prts-protected-${slot}-x`,`${p.x}px`)
40
+ write(`--prts-protected-${slot}-y`,`${p.y}px`)
41
+ if(slot==='sidebar')write('--prts-protected-sidebar-width',`${p.width}px`)
31
42
  }else{
32
- root.style.removeProperty(`--prts-protected-${slot}-x`)
33
- root.style.removeProperty(`--prts-protected-${slot}-y`)
34
- if(slot==='sidebar')root.style.removeProperty('--prts-protected-sidebar-width')
43
+ write(`--prts-protected-${slot}-x`,null)
44
+ write(`--prts-protected-${slot}-y`,null)
45
+ if(slot==='sidebar')write('--prts-protected-sidebar-width',null)
35
46
  }
36
47
  }
48
+ for(const args of writes)set(...args)
37
49
  }
38
50
  function schedule(){if(!disposed && observing && frame===undefined)frame=raf(update)}
39
51
  // ResizeObserver runs after layout, before paint. Deferring again to rAF
@@ -48,6 +60,8 @@ export function createBackgroundLayout({window,document}) {
48
60
  const attributes=new window.MutationObserver(schedule)
49
61
  function clear(){
50
62
  caf(frame);frame=undefined;resize?.disconnect();mutations.disconnect();attributes.disconnect();window.removeEventListener('resize',schedule)
63
+ for(const node of sidebarTargets)for(const axis of ['x','y','width'])node.style.removeProperty(`--prts-protected-sidebar-${axis}`)
64
+ sidebarTargets.clear()
51
65
  hostFrame=undefined;operation=undefined;sessions=undefined;observing=false
52
66
  for(const slot of ['full','conversation','sidebar'])for(const axis of ['x','y','width'])root.style.removeProperty(`--prts-protected-${slot}-${axis}`)
53
67
  }
@@ -23,6 +23,8 @@ function isPendingInteractionAction(button) {
23
23
  function classify(button, operation) {
24
24
  // The host's process disclosure owns its transparent, full-width hit area.
25
25
  if (button.hasAttribute('data-turn-process')) return null
26
+ // Schedule cards use an absolute transparent button as the whole-card hit area.
27
+ if (button.parentElement?.matches('[data-tool="schedule_create"]') && hasClassSuffix(button, '_cardOpen')) return null
26
28
  if (button.querySelector('[data-compaction-disclosure]')) return null
27
29
  // Notification headers belong to the card surface, not standalone buttons.
28
30
  if (button.parentElement?.matches('[data-turn-trigger]')) return null
@@ -49,6 +51,8 @@ function classify(button, operation) {
49
51
  if (approval) return hasClassSuffix(button, '_reject') ? 'danger' : 'cta'
50
52
  if (hasClassSuffix(button, '_modalAction') || button.closest('[role="dialog"]')) return 'cta'
51
53
 
54
+ const userRow = closestWithClassSuffix(button, '_userRow')
55
+ if (userRow?.matches('[data-submission-echo], [data-pending-steering]')) return 'micro'
52
56
  if (button.closest('[data-chat-flow-kind], [data-message-role]')) return 'micro'
53
57
  return null
54
58
  }
@@ -129,7 +133,7 @@ export function createConversationControlAdapter({ document, window }) {
129
133
  subtree: true,
130
134
  childList: true,
131
135
  attributes: true,
132
- attributeFilter: ['class', 'data-approval-key', 'data-turn-process'],
136
+ attributeFilter: ['class', 'data-approval-key', 'data-turn-process', 'data-submission-echo', 'data-pending-steering', 'data-chat-flow-kind', 'data-message-role'],
133
137
  })
134
138
  },
135
139
  dispose() {
@@ -166,24 +170,36 @@ function mountBodyTargetFrame(document, window, operation) {
166
170
  }
167
171
  let target=null, hovered=null, focused=null, raf=0, pressed=false, releasePending=false, disposed=false
168
172
  let pointer={x:0,y:0}, state=null, settled=false, tween=null, bounds=null, boundsDirty=false, hitDirty=false, exitGoal=null, motionDuration=240, targetArm=7, retiringBounds=null, targetRetires=false
169
- const resizeObserver=window.ResizeObserver?new window.ResizeObserver(()=>{boundsDirty=true;wake()}):null
173
+ let moveRaf=0, pendingMove=null
174
+ const resizeObserver=window.ResizeObserver?new window.ResizeObserver(entries=>{
175
+ if(!target)return
176
+ const entry=entries.find(item=>item.target===target),size=entry?.borderBoxSize?.[0]
177
+ if(size && bounds && Math.abs(size.inlineSize-bounds.width)<.5 && Math.abs(size.blockSize-bounds.height)<.5)return
178
+ const box=target.getBoundingClientRect()
179
+ if(bounds && ['left','top','width','height'].every(key=>bounds[key]===box[key]))return
180
+ boundsDirty=true;wake()
181
+ }):null
170
182
  const reduced=window.matchMedia?.('(prefers-reduced-motion: reduce)')
171
183
  const fine=window.matchMedia?.('(hover:hover) and (pointer:fine)')
172
184
  let activeModal=null
173
185
  const find=node=>{
174
186
  if(activeModal && !activeModal.contains(node))return null
175
187
  if (!node?.closest || node.closest('[data-prts-workspace-actions],[data-prts-session-actions]')) return null
176
- const card=node.closest('[data-presented-file],[data-changed-files]')
188
+ const schedule=node.closest('[data-tool="schedule_create"]')
189
+ const scheduleOpen=schedule && [...schedule.children].find(child=>child.tagName==='BUTTON' && hasClassSuffix(child,'_cardOpen') && !child.matches(':disabled,[aria-disabled="true"]'))
177
190
  const button=node.closest('button')
191
+ // The transparent hit layer targets the card; nested actions keep their own frame.
192
+ const card=node.closest('[data-presented-file],[data-changed-files]') || (scheduleOpen && (!button || button===scheduleOpen)?schedule:null)
178
193
  const pendingAction=operation.contains(button) && isPendingInteractionAction(button) ? button : null
179
194
  const candidate=card || pendingAction || node.closest(selector)
180
195
  return candidate && !candidate.matches(':disabled,[aria-disabled="true"]') && !candidate.closest('[inert]') ? candidate : null
181
196
  }
182
- const measure=()=>{bounds=target.getBoundingClientRect();targetArm=7+Math.max(0,Math.min(1,(Math.min(bounds.width,bounds.height)-36)/36))*5;boundsDirty=false}
197
+ const measure=(box)=>{bounds=box || target.getBoundingClientRect();targetArm=7+Math.max(0,Math.min(1,(Math.min(bounds.width,bounds.height)-36)/36))*5;boundsDirty=false}
183
198
  const wake=()=>{if(!raf && !disposed)raf=window.requestAnimationFrame(draw)}
184
199
  const choose=()=>{
185
200
  const next=(hovered?.isConnected?hovered:null) || (focused?.isConnected?focused:null)
186
201
  if(next!==target){
202
+ const nextBounds=next?.getBoundingClientRect()
187
203
  const retire=!next && target && !target.isConnected && targetRetires && releasePending && bounds && !reduced?.matches
188
204
  imageRetiring=false
189
205
  retiringBounds=retire?{left:bounds.left,top:bounds.top,width:bounds.width,height:bounds.height}:null
@@ -195,12 +211,13 @@ function mountBodyTargetFrame(document, window, operation) {
195
211
  if(target){
196
212
  targetRetires=hasClassSuffix(target,'_toBottom') || isPendingInteractionAction(target)
197
213
  target.setAttribute('data-prts-corner-target','');showFrame()
198
- measure();const box=bounds
199
- if(!state || state.alpha<.01)state={x:(hovered?pointer.x:box.left+box.width/2)-10,y:(hovered?pointer.y:box.top+box.height/2)-10,w:20,h:20,alpha:0,arm:7}
200
- const style=window.getComputedStyle(target)
201
- frame.style.color=style.getPropertyValue(target.getAttribute('data-prts-conversation-control')==='danger'?'--prts-red':'--prts-yellow')
214
+ measure(nextBounds);const box=bounds
215
+ state={x:(hovered?pointer.x:box.left+box.width/2)-10,y:(hovered?pointer.y:box.top+box.height/2)-10,w:20,h:20,alpha:0,arm:7}
216
+ frame.style.opacity='0'
217
+ frame.style.color=target.getAttribute('data-prts-conversation-control')==='danger'?window.getComputedStyle(target).getPropertyValue('--prts-red'):'var(--prts-corner-color, var(--prts-yellow))'
202
218
  }
203
219
  }
220
+ if(!target && !state?.alpha)hideFrame()
204
221
  if(!settled || !target && state?.alpha>0)wake()
205
222
  }
206
223
  function draw(time) {
@@ -227,7 +244,7 @@ function mountBodyTargetFrame(document, window, operation) {
227
244
  tween={from:{...state},to:{...goal},start:time,duration:remaining,pressed}
228
245
  }
229
246
  // All channels share one endpoint; no separate thresholds or late position snap.
230
- const progress=reduced?.matches || settled || !tween.duration?1:Math.min(1,(time-tween.start)/tween.duration)
247
+ const progress=reduced?.matches || settled || !tween.duration || (!state.alpha && !goal.alpha)?1:Math.min(1,(time-tween.start)/tween.duration)
231
248
  const ease=1-(1-progress)**3
232
249
  for(const key of keys)state[key]=tween.from[key]+(goal[key]-tween.from[key])*ease
233
250
  const moving=progress<1
@@ -251,17 +268,28 @@ function mountBodyTargetFrame(document, window, operation) {
251
268
  else if(moving)wake()
252
269
  else {tween=null;if(!state.alpha)hideFrame()}
253
270
  }
254
- const move=event=>{
271
+ const applyMove=event=>{
255
272
  if(document.hidden || event.pointerType==='touch' || !fine?.matches)return
256
273
  pointer={x:event.clientX,y:event.clientY}
257
274
  const next=find(event.target)
258
275
  if(next===hovered && (next || !state?.alpha))return
259
276
  hovered=next;choose()
260
277
  }
261
- const leave=()=>{hovered=null;choose()}
278
+ const clearMove=()=>{window.cancelAnimationFrame(moveRaf);moveRaf=0;pendingMove=null}
279
+ const flushMove=()=>{
280
+ const event=pendingMove;clearMove()
281
+ if(event)applyMove(event)
282
+ }
283
+ const move=event=>{
284
+ if(document.hidden || event.pointerType==='touch' || !fine?.matches)return
285
+ pendingMove={target:event.target,clientX:event.clientX,clientY:event.clientY,pointerType:event.pointerType}
286
+ if(!moveRaf)moveRaf=window.requestAnimationFrame(flushMove)
287
+ }
288
+ const leave=()=>{clearMove();hovered=null;choose()}
262
289
  const focus=event=>{focused=event.target.matches?.(':focus-visible')?find(event.target):null;choose()}
263
290
  const blur=()=>{focused=null;choose()}
264
291
  const down=event=>{
292
+ flushMove()
265
293
  pointer={x:event.clientX,y:event.clientY}
266
294
  if(event.pointerType==='touch')hovered=find(event.target)
267
295
  choose()
@@ -275,6 +303,7 @@ function mountBodyTargetFrame(document, window, operation) {
275
303
  }
276
304
  const up=event=>{release();if(event.pointerType==='touch')hovered=null;choose()}
277
305
  const reset=()=>{
306
+ clearMove()
278
307
  target?.removeAttribute('data-prts-corner-target');hideFrame()
279
308
  hovered=focused=target=null;state=tween=bounds=exitGoal=retiringBounds=null
280
309
  imageRetiring=false
@@ -317,6 +346,7 @@ function mountBodyTargetFrame(document, window, operation) {
317
346
  ]
318
347
  for(const [node,event,handler] of listeners)node.addEventListener(event,handler)
319
348
  return ()=>{
349
+ clearMove()
320
350
  modalObserver?.disconnect();document.removeEventListener('toggle',modalToggle,true)
321
351
  disposed=true;target?.removeAttribute('data-prts-corner-target');hideFrame();window.cancelAnimationFrame(raf);resizeObserver?.disconnect()
322
352
  document.removeEventListener('scroll',scroll,true);window.removeEventListener('resize',reset)
@@ -7,8 +7,10 @@ export function createCursorAdapter({ document, window, assets }) {
7
7
  let handPhase
8
8
  let gestureAssets = assets
9
9
  let textTarget
10
+ let moveFrame, pendingMove
10
11
  let lastPoint
11
- const contentSelector = '[data-markdown], [data-slot*="markdown" i], [class*="markdown" i], [data-chat-flow-kind="user"] [class$="_bubble"]'
12
+ const heroTextSelector = '[data-phase="hero"] [class$="_titleGroup"]'
13
+ const contentSelector = '[data-markdown], [data-slot*="markdown" i], [class*="markdown" i], :is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"], [data-submission-echo], [data-pending-steering]) [class$="_bubble"]'
12
14
  const controlSelector = 'a[href], button, summary, input, textarea, select, [contenteditable], [role="button"], [role="toolbar"], [role="tab"]'
13
15
  function clearTextHit() {
14
16
  textTarget?.removeAttribute('data-prts-text-hit')
@@ -25,8 +27,8 @@ export function createCursorAdapter({ document, window, assets }) {
25
27
  if (event.pointerType !== 'mouse') return
26
28
  lastPoint = { clientX: event.clientX, clientY: event.clientY, pointerType: 'mouse' }
27
29
  const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement
28
- const content = target?.closest?.(contentSelector)
29
- if (!content?.closest('[data-chat-flow-kind], [data-message-role]') || target.closest(controlSelector)) return
30
+ const content = target?.closest?.(`${contentSelector}, ${heroTextSelector}`)
31
+ if (!content || (!content.matches(heroTextSelector) && !content.closest('[data-chat-flow-kind], [data-message-role], [data-submission-echo], [data-pending-steering]')) || target.closest(controlSelector)) return
30
32
  const style = window.getComputedStyle(target)
31
33
  if (style.userSelect === 'none' || !style.cursor.includes('data:image/png')) return
32
34
  // Caret APIs return the nearest insertion point even over empty space.
@@ -89,6 +91,7 @@ export function createCursorAdapter({ document, window, assets }) {
89
91
  let originalPriority
90
92
  let appliedCursor
91
93
  let appliedPriority
94
+ let lastHandFrame
92
95
  function ownsHandCursor() {
93
96
  return handTarget?.style.cursor === appliedCursor
94
97
  && handTarget.style.getPropertyPriority('cursor') === appliedPriority
@@ -105,6 +108,7 @@ export function createCursorAdapter({ document, window, assets }) {
105
108
  }
106
109
  handTarget?.removeAttribute('data-prts-hand-active')
107
110
  handTarget = undefined
111
+ lastHandFrame = undefined
108
112
  }
109
113
  // Cursor is inherited. Animating a large container still restyles its subtree.
110
114
  // Keep those background hits static, while controls retain native animation.
@@ -129,7 +133,9 @@ export function createCursorAdapter({ document, window, assets }) {
129
133
  }
130
134
  function applyHandFrame() {
131
135
  if (stopIfHandChanged() || !handTarget) return
136
+ if(lastHandFrame === handCursor && ownsHandCursor())return
132
137
  handTarget.style.setProperty('cursor', handCursor)
138
+ lastHandFrame = handCursor
133
139
  appliedCursor = handTarget.style.cursor
134
140
  appliedPriority = handTarget.style.getPropertyPriority('cursor')
135
141
  }
@@ -155,20 +161,27 @@ export function createCursorAdapter({ document, window, assets }) {
155
161
  }
156
162
  function play(frames, done) {
157
163
  clearTimer()
158
- let index = 0
164
+ const started = window.performance.now()
165
+ let index = 0, deadline = frames[0]?.duration ?? 0
159
166
  function next() {
160
167
  timer = undefined
161
168
  if (stopIfHandChanged()) return
162
- // The source ANI ends with a 60-second hold frame. Use the static state instead.
169
+ const elapsed = window.performance.now() - started
170
+ // Skip expired frames after a busy main thread; never replay a backlog.
171
+ while (index < frames.length - 1 && elapsed >= deadline) {
172
+ index += 1
173
+ deadline += frames[index]?.duration ?? 0
174
+ }
175
+ // The source ANI ends with a 60-second hold frame, which is not animated.
163
176
  if (index >= frames.length - 1) { done(); return }
164
- const frame = frames[index++]
165
- show(frame.url)
177
+ show(frames[index].url)
166
178
  if (!handCursor) return
167
- timer = window.setTimeout(next, frame.duration)
179
+ timer = window.setTimeout(next, Math.max(1, deadline - (window.performance.now() - started)))
168
180
  }
169
181
  next()
170
182
  }
171
183
  function down(event) {
184
+ clearPendingMove()
172
185
  if (event.pointerType !== 'mouse' || event.button !== 0 || event.isPrimary === false) return
173
186
  updateResize(event)
174
187
  updateTextHit(event)
@@ -202,13 +215,32 @@ export function createCursorAdapter({ document, window, assets }) {
202
215
  if (handPhase === 'press') return
203
216
  releaseHand()
204
217
  }
205
- function move(event) {
218
+ function applyMove(event) {
206
219
  updateResize(event)
207
220
  updateTextHit(event)
208
221
  if (handCursor) followHand(event.target?.nodeType === 1 ? event.target : event.target?.parentElement)
209
222
  if (pointerId !== undefined && event.pointerId === pointerId && !(event.buttons & 1)) reset()
210
223
  }
224
+ function clearPendingMove() {
225
+ if (moveFrame !== undefined) window.cancelAnimationFrame?.(moveFrame)
226
+ moveFrame = undefined
227
+ pendingMove = undefined
228
+ }
229
+ function move(event) {
230
+ if (event.pointerType !== 'mouse') return
231
+ // Lost releases must clear synchronously even when the next paint is delayed.
232
+ if (pointerId !== undefined && event.pointerId === pointerId && !(event.buttons & 1)) reset()
233
+ if (!window.requestAnimationFrame) { applyMove(event); return }
234
+ pendingMove = { target:event.target, pointerType:event.pointerType, pointerId:event.pointerId,
235
+ buttons:event.buttons, clientX:event.clientX, clientY:event.clientY }
236
+ if (moveFrame === undefined) moveFrame = window.requestAnimationFrame(() => {
237
+ const latest = pendingMove
238
+ clearPendingMove()
239
+ if (latest?.target?.isConnected) applyMove(latest)
240
+ })
241
+ }
211
242
  function resetInteraction() {
243
+ clearPendingMove()
212
244
  reset()
213
245
  clearResize()
214
246
  clearTextHit()
@@ -17,7 +17,7 @@ export function createHostGeometryAdapter({ document, window }) {
17
17
  const root = document?.documentElement
18
18
  let frame
19
19
  let sidebar
20
- let previousSidebarWidth
20
+ const sidebarWidths = new Map()
21
21
  let resizeObserver
22
22
  let frameRequest
23
23
  let resizeSettleTimer
@@ -35,7 +35,11 @@ export function createHostGeometryAdapter({ document, window }) {
35
35
  }
36
36
  if (sidebar?.isConnected) {
37
37
  const width = cssPixels(sidebar.getBoundingClientRect().width)
38
- if (root.style.getPropertyValue('--prts-sidebar-width') !== width) root.style.setProperty('--prts-sidebar-width', width)
38
+ for (const node of [sidebar, document.querySelector('[data-prts-nav-rail]')]) {
39
+ if (!node) continue
40
+ if (!sidebarWidths.has(node)) sidebarWidths.set(node, [node.style.getPropertyValue('--prts-sidebar-width'), node.style.getPropertyPriority('--prts-sidebar-width')])
41
+ if (node.style.getPropertyValue('--prts-sidebar-width') !== width) node.style.setProperty('--prts-sidebar-width', width)
42
+ }
39
43
  }
40
44
  return top
41
45
  }
@@ -103,8 +107,11 @@ export function createHostGeometryAdapter({ document, window }) {
103
107
  else root.style.removeProperty(TOP_INSET_PROPERTY)
104
108
  previousValue = undefined
105
109
  previousPriority = undefined
106
- if (previousSidebarWidth) root.style.setProperty('--prts-sidebar-width', previousSidebarWidth)
107
- else root.style.removeProperty('--prts-sidebar-width')
110
+ for (const [node, [value, priority]] of sidebarWidths) {
111
+ if (value) node.style.setProperty('--prts-sidebar-width', value, priority)
112
+ else node.style.removeProperty('--prts-sidebar-width')
113
+ }
114
+ sidebarWidths.clear()
108
115
  sidebar = undefined
109
116
  frame = undefined
110
117
  observedFrameHeight = undefined
@@ -121,7 +128,6 @@ export function createHostGeometryAdapter({ document, window }) {
121
128
  if (started) dispose()
122
129
  frame = nextFrame
123
130
  sidebar = frame.querySelector?.('[data-prts-region="sessions"]')
124
- previousSidebarWidth = root.style.getPropertyValue('--prts-sidebar-width')
125
131
  previousValue = root.style.getPropertyValue(TOP_INSET_PROPERTY)
126
132
  previousPriority = root.style.getPropertyPriority(TOP_INSET_PROPERTY)
127
133
  started = true
@@ -223,6 +223,7 @@ export function applyPrtsPlugin(ctx, environment) {
223
223
  persistAndApply(updatePreferenceValue(preferences, key, value))
224
224
  },
225
225
  onPreferencePreview(key, value) {
226
+ if (key === 'cornerColor') { document.documentElement.style.setProperty('--prts-corner-color', String(value)); return }
226
227
  if (key === 'particleTraversalSpeed') {
227
228
  particleField.update(updatePreferenceValue(preferences, key, value))
228
229
  return
@@ -39,6 +39,8 @@ export const DEFAULT_PREFERENCES = Object.freeze({
39
39
  bakerBlur: 8,
40
40
  classicUserBlur: 8,
41
41
  cursor: 'prts',
42
+ cornerColor: '#f0c800',
43
+ cornerCustomColor: '#f0c800',
42
44
  conversationParticleDensity: 'sparse',
43
45
  heroParticleDensity: 'light',
44
46
  particleTraversalSpeed: PARTICLE_TRAVERSAL_SPEED_DEFAULT,
@@ -105,7 +107,7 @@ export const PREFERENCE_GROUPS = Object.freeze({
105
107
  background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'rightSidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'rightSidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
106
108
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
107
109
  material: Object.freeze(['glass', 'composerGlass']),
108
- accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
110
+ accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor', 'cornerColor', 'cornerCustomColor']),
109
111
  aiTools: Object.freeze(['aiToolsEnabled']),
110
112
  navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']),
111
113
  conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'bakerLoading', 'conversationEmblem']),
@@ -229,6 +231,8 @@ export function normalizePreferences(value) {
229
231
  backgroundDim: typeof input.backgroundDim === 'number' && Number.isFinite(input.backgroundDim) ? Math.min(.9, Math.max(0, input.backgroundDim)) : .45,
230
232
  composerGlass: booleanOr(input.composerGlass, DEFAULT_PREFERENCES.composerGlass),
231
233
  bootAnimation: booleanOr(input.bootAnimation, DEFAULT_PREFERENCES.bootAnimation),
234
+ cornerCustomColor: colorOr(input.cornerCustomColor, DEFAULT_PREFERENCES.cornerCustomColor),
235
+ cornerColor: colorOr(input.cornerColor, DEFAULT_PREFERENCES.cornerColor),
232
236
  cursor: input.cursor === 'system' ? 'system' : DEFAULT_PREFERENCES.cursor,
233
237
  conversationParticleDensity: enumOr(legacyDensities.conversation, PARTICLE_DENSITIES, particlePreset.conversationParticleDensity),
234
238
  heroParticleDensity: enumOr(legacyDensities.hero, PARTICLE_DENSITIES, particlePreset.heroParticleDensity),
@@ -283,6 +287,10 @@ export function updatePreferenceValue(value, key, nextValue) {
283
287
  if (key === 'preset') return applyVisualPreset(value, nextValue)
284
288
  if (key === 'particleDetail') return applyParticleDetail(value, nextValue)
285
289
  const current = normalizePreferences(value)
290
+ if (key === 'cornerCustomColor') {
291
+ const color = colorOr(nextValue, current.cornerCustomColor)
292
+ return normalizePreferences({ ...current, cornerColor: color, cornerCustomColor: color })
293
+ }
286
294
  if (key === 'sessionFlowPalette') {
287
295
  const palette = enumOr(nextValue, SESSION_FLOW_PALETTE_IDS, current.sessionFlowPalette)
288
296
  const colors = SESSION_FLOW_PALETTES[palette]
@@ -27,9 +27,12 @@ export function createPreviewContentCache(document, render = renderPreviewMarkdo
27
27
  const html = render(text)
28
28
  const plain = document.createElement('div')
29
29
  plain.innerHTML = html
30
- const summary = plain.textContent.replace(/\s+/g, ' ').trim().slice(0, 100)
30
+ const fullSummary = plain.textContent.replace(/\s+/g, ' ').trim()
31
+ const summary = fullSummary.slice(0, 100)
32
+ const truncated = fullSummary.length > summary.length
33
+ const needsDetail = truncated || Boolean(plain.querySelector('a,pre,ul,ol,table,blockquote,h1,h2,h3,h4,h5,h6,br') || plain.children.length > 1)
31
34
  const cost = text.length + html.length + summary.length
32
- const value = {html, summary, cost}
35
+ const value = {html, summary, cost, needsDetail, truncated}
33
36
  if (cost <= 262144) {
34
37
  while (entries.size && (entries.size >= 32 || size + cost > 262144)) {
35
38
  const key = entries.keys().next().value
@@ -293,12 +293,29 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
293
293
  const height = Math.max(1, (viewport?.height ?? window.innerHeight) - 16)
294
294
  const place = (surface, reference, preferredTop, noticeOwner, anchorBottom = preferredTop) => {
295
295
  surface.style.maxWidth = 'none'
296
- surface.style.maxHeight = 'none'
296
+ let fixedEdge
297
+ if (surface === panel) {
298
+ // Pick a direction once, before async replies arrive; keep the same edge
299
+ // while content grows and let the existing scroll surface absorb overflow.
300
+ if (!current.placement) {
301
+ const below = top + height - preferredTop
302
+ const above = anchorBottom - top
303
+ current.placement = below < 160 && above > below
304
+ ? { direction: 'above', edge: anchorBottom }
305
+ : { direction: 'below', edge: preferredTop }
306
+ }
307
+ fixedEdge = Math.max(top, Math.min(current.placement.edge, top + height))
308
+ const available = current.placement.direction === 'above' ? fixedEdge - top : top + height - fixedEdge
309
+ surface.style.maxHeight = Math.max(1, available) + 'px'
310
+ surface.setAttribute('data-prts-preview-direction', current.placement.direction)
311
+ } else surface.style.maxHeight = 'none'
297
312
  const bounds = surface.getBoundingClientRect()
298
313
  const x = reference ? reference.right + 10 : left
299
- const fitsVertically = y => y >= top && y + bounds.height <= top + height
314
+ const fitsVertically = y => y >= top - 1 && y + bounds.height <= top + height + 1
300
315
  const above = anchorBottom - bounds.height
301
- const y = !fitsVertically(preferredTop) && fitsVertically(above) ? above : preferredTop
316
+ const y = surface === panel
317
+ ? current.placement.direction === 'above' ? fixedEdge - bounds.height : fixedEdge
318
+ : !fitsVertically(preferredTop) && fitsVertically(above) ? above : preferredTop
302
319
  const insufficient = Boolean(reference) && (x < left || x + bounds.width > left + width || !fitsVertically(y))
303
320
  surface.toggleAttribute('data-prts-preview-no-space', insufficient)
304
321
  const noticeKey = surface === panel ? 'spaceNotice' : 'detailSpaceNotice'
@@ -319,6 +336,7 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
319
336
  surface.style.top = y + 'px'
320
337
  return !insufficient
321
338
  }
339
+ updateReplyAvailability()
322
340
  const menu = current.anchor?.closest('[data-prts-session-summary-popover]')
323
341
  const reference = menu?.getBoundingClientRect()
324
342
  const row = current.anchor?.getBoundingClientRect()
@@ -343,6 +361,8 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
343
361
  current.spaceNotice?.remove()
344
362
  current.detailSpaceNotice?.remove()
345
363
  panel.removeAttribute('data-prts-preview-no-space')
364
+ panel.removeAttribute('data-prts-preview-direction')
365
+ panel.style.removeProperty('max-height')
346
366
  positionObserver?.disconnect()
347
367
  window.cancelAnimationFrame(positionFrame)
348
368
  window.removeEventListener('resize', schedulePosition)
@@ -537,8 +557,24 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
537
557
  detailEntry?.removeAttribute('data-prts-reply-active')
538
558
  detailEntry = undefined
539
559
  }
560
+ const replyIsTruncated = item => {
561
+ const summary = item.querySelector('span')
562
+ return item.dataset.prtsSummaryTruncated === 'true' || summary.scrollHeight > summary.clientHeight + 1 || summary.scrollWidth > summary.clientWidth + 1
563
+ }
564
+ const updateReplyAvailability = () => {
565
+ const entries = [...body.querySelectorAll('[data-prts-reply-entry]')].map(item => [item, replyIsTruncated(item)])
566
+ for (const [item, truncated] of entries) {
567
+ const needed = truncated || item.dataset.prtsReplyTruncated === 'true'
568
+ item.toggleAttribute('data-prts-reply-overflow', truncated)
569
+ item.toggleAttribute('data-prts-reply-complete', !needed)
570
+ item.tabIndex = needed ? 0 : -1
571
+ item.setAttribute('aria-disabled', String(!needed))
572
+ if (!needed && detailEntry === item) hideDetail()
573
+ }
574
+ }
540
575
  const showDetail = (reply, index, item, refresh = false) => {
541
576
  keep()
577
+ if (!reply.tool && item.hasAttribute('data-prts-reply-complete')) { hideDetail(); return }
542
578
  if (!refresh && detailKey === reply.key && current.detail && !current.detail.hidden) return
543
579
  if (!current.detail) {
544
580
  const detail = panel.ownerDocument.createElement('section')
@@ -708,8 +744,15 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
708
744
  icon.append(outline)
709
745
  heading.prepend(icon)
710
746
  const summary = panel.ownerDocument.createElement('span')
711
- summary.textContent = current.contentCache(reply.text).summary
712
- item.append(heading, summary)
747
+ const content = current.contentCache(reply.text)
748
+ summary.textContent = content.summary
749
+ item.dataset.prtsReplyTruncated = String(content.needsDetail)
750
+ item.dataset.prtsSummaryTruncated = String(content.truncated)
751
+ const ellipsis = panel.ownerDocument.createElement('i')
752
+ ellipsis.setAttribute('data-prts-reply-ellipsis', '')
753
+ ellipsis.setAttribute('aria-hidden', 'true')
754
+ ellipsis.textContent = '…'
755
+ item.append(heading, summary, ellipsis)
713
756
  item.addEventListener('pointermove', () => showDetail(reply, index, item))
714
757
  item.addEventListener('focus', () => showDetail(reply, index, item))
715
758
  item.addEventListener('click', () => showDetail(reply, index, item))
@@ -725,6 +768,7 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
725
768
  if (scrollAnchor?.isConnected) body.scrollTop += scrollAnchor.getBoundingClientRect().top - anchorTop
726
769
  else if (previousTop === 0) body.scrollTop = 0
727
770
  historyKeys = replies
771
+ schedulePosition()
728
772
  previousText = undefined
729
773
  previousQuestion = undefined
730
774
  return
@@ -862,6 +862,28 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
862
862
  }
863
863
 
864
864
 
865
+ let pickupRow, pickupTimer, pickupRoot
866
+ function clearPickup() {
867
+ if (pickupTimer !== undefined) window.clearTimeout(pickupTimer)
868
+ pickupTimer = undefined
869
+ pickupRow?.removeAttribute('data-prts-pickup-ready')
870
+ pickupRow = undefined
871
+ }
872
+ function pickupOver(event) {
873
+ const row = event.target.closest?.('[data-prts-session-row]')
874
+ if (row === pickupRow) return
875
+ clearPickup()
876
+ if (!row) return
877
+ pickupRow = row
878
+ pickupTimer = window.setTimeout(() => {
879
+ pickupTimer = undefined
880
+ if (row.isConnected && pickupRow === row) row.setAttribute('data-prts-pickup-ready', '')
881
+ }, 100)
882
+ }
883
+ function pickupOut(event) {
884
+ if (pickupRow && !pickupRow.contains(event.relatedTarget)) clearPickup()
885
+ }
886
+
865
887
  function queueScan() {
866
888
  if (!adapterActive || scanFrame !== undefined) return
867
889
  const requestFrame = window?.requestAnimationFrame?.bind(window) ?? (callback => window?.setTimeout?.(callback, 0))
@@ -1021,8 +1043,18 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
1021
1043
  if (observer || !window?.MutationObserver) return
1022
1044
  const observationRoot = document.querySelector('[data-prts-region="sessions"]')
1023
1045
  if (!observationRoot) return
1046
+ pickupRoot = observationRoot
1047
+ pickupRoot.addEventListener('pointerover', pickupOver)
1048
+ pickupRoot.addEventListener('pointerout', pickupOut)
1024
1049
  observer = new window.MutationObserver(mutations => {
1025
- const relevant = mutations.filter(mutation => !mutation.target.closest?.('[data-prts-owned-session-summary]'))
1050
+ const relevant = mutations.filter(mutation => {
1051
+ if (mutation.target.closest?.('[data-prts-owned-session-summary]')) return false
1052
+ if (mutation.type === 'attributes' && mutation.attributeName === 'class' && !mutation.target.closest('[role="treeitem"]')) {
1053
+ const stableClasses = value => (value ?? '').split(/\s+/).filter(token => token && !token.endsWith('_quietBars')).sort().join(' ')
1054
+ if (stableClasses(mutation.oldValue) === stableClasses(mutation.target.getAttribute('class'))) return false
1055
+ }
1056
+ return true
1057
+ })
1026
1058
  const freshRow = 'div[role="treeitem"]:not([data-prts-session-row]):not([data-prts-workspace-row])'
1027
1059
  const mountsRows = relevant.some(mutation => mutation.type === 'childList'
1028
1060
  && Array.from(mutation.addedNodes).some(node => node.nodeType === 1
@@ -1040,9 +1072,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
1040
1072
  childList: true,
1041
1073
  attributes: true,
1042
1074
  attributeFilter: ['aria-expanded', 'aria-selected', 'class', 'data-state'],
1075
+ attributeOldValue: true,
1043
1076
  })
1044
1077
  },
1045
1078
  dispose() {
1079
+ clearPickup()
1080
+ pickupRoot?.removeEventListener('pointerover', pickupOver)
1081
+ pickupRoot?.removeEventListener('pointerout', pickupOut)
1082
+ pickupRoot = undefined
1046
1083
  adapterActive = false
1047
1084
  disconnectSummarySources()
1048
1085
  cancelReveal()
@@ -19,6 +19,7 @@ const ROOT_ATTRIBUTES = [
19
19
  'data-prts-background-mode',
20
20
  ]
21
21
  const SESSION_FLOW_COLOR_PROPERTIES = {
22
+ '--prts-corner-color': 'cornerColor',
22
23
  '--prts-session-flow-back': 'sessionFlowColorBack',
23
24
  '--prts-session-flow-core': 'sessionFlowColorCore',
24
25
  '--prts-session-flow-front': 'sessionFlowColorFront',
@@ -14,6 +14,7 @@ const ROOT_ATTRIBUTES = [
14
14
  'data-prts-conversation-style',
15
15
  ]
16
16
  const SESSION_FLOW_COLOR_PROPERTIES = {
17
+ '--prts-corner-color': 'cornerColor',
17
18
  '--prts-session-flow-back': 'sessionFlowColorBack',
18
19
  '--prts-session-flow-core': 'sessionFlowColorCore',
19
20
  '--prts-session-flow-front': 'sessionFlowColorFront',