Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/client/app/components/UsageStatusModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useProjects } from '../composables/useProjects'
import { useRpc } from '../composables/useRpc'
import {
normalizeAccountRateLimits,
mergeAccountRateLimits,
formatRateLimitWindowDuration,
type RateLimitBucket
} from '../../shared/account-rate-limits'
Expand Down Expand Up @@ -99,8 +100,10 @@ const closeUsageStatus = () => {
resetUsageStatusState()
}

const applyUsageStatusSnapshot = (value: unknown) => {
buckets.value = normalizeAccountRateLimits(value)
const applyUsageStatusSnapshot = (value: unknown, sparse = false) => {
buckets.value = sparse
? mergeAccountRateLimits(buckets.value, value)
: normalizeAccountRateLimits(value)
error.value = null
}

Expand Down Expand Up @@ -130,7 +133,7 @@ const openUsageStatus = async () => {
return
}

applyUsageStatusSnapshot(notification.params)
applyUsageStatusSnapshot(notification.params, true)
loading.value = false
})

Expand Down
26 changes: 24 additions & 2 deletions packages/client/shared/account-rate-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ const normalizeRateLimitBucket = (value: unknown): RateLimitBucket | null => {

const collectRateLimitCandidates = (value: unknown) => {
const root = isObjectRecord(value) ? value : null
const rateLimits = Array.isArray(root?.rateLimits) ? root.rateLimits : []
const rateLimits = Array.isArray(root?.rateLimits)
? root.rateLimits
: isObjectRecord(root?.rateLimits)
? [root.rateLimits]
: []
const rateLimitsByLimitId = isObjectRecord(root?.rateLimitsByLimitId)
? Object.values(root.rateLimitsByLimitId)
: []
Expand All @@ -133,7 +137,13 @@ const collectRateLimitCandidates = (value: unknown) => {
return [...rateLimits, ...rateLimitsByLimitId]
}

return Array.isArray(value) ? value : []
if (Array.isArray(value)) {
return value
}

return root && ('primary' in root || 'secondary' in root)
? [root]
: []
}

export const normalizeAccountRateLimits = (value: unknown): RateLimitBucket[] => {
Expand Down Expand Up @@ -166,6 +176,18 @@ export const normalizeAccountRateLimits = (value: unknown): RateLimitBucket[] =>
})
}

/**
* Merge a sparse rolling update into the last authoritative snapshot.
* Missing/null values in an update never erase facts already observed.
*/
export const mergeAccountRateLimits = (
existing: readonly RateLimitBucket[],
update: unknown
): RateLimitBucket[] => normalizeAccountRateLimits([
...existing,
...normalizeAccountRateLimits(update)
])

export const formatRateLimitWindowDuration = (value: number | null) => {
if (value == null || value <= 0) {
return null
Expand Down
30 changes: 26 additions & 4 deletions packages/webxr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ The package is a progressive enhancement. The existing dashboard remains the pri
## Requirements

- A secure context. `https://` is required on a remote headset; the browser's `localhost` exception does not apply to a plain LAN IP.
- A browser reporting `navigator.xr.isSessionSupported('immersive-vr')`.
- A browser reporting `immersive-vr` or `immersive-ar` support. VR is preferred when both are available; AR-only devices enter their supported mode.
- A materialized Codori project or projectless-chat thread.
- The Codori server started with realtime voice enabled if voice controls are required.

The entry screen checks support without user-agent sniffing. `requestSession('immersive-vr')` and microphone access occur only after explicit user actions.
The entry screen probes the two modes independently without user-agent sniffing, so a failed AR probe does not disable working VR. `requestSession()` and microphone access occur only after explicit user actions.

## Development

Expand All @@ -32,6 +32,11 @@ file-change, tool, search, and background-terminal surfaces without connecting
to a live workspace. This fixture is useful for browser-side text density,
border, color, and layout comparisons; it does not reproduce headset
framebuffer scaling or fixed foveation.
The kitchen-sink fixture also opens the lime status window with primary and
secondary quota windows, context state, and the initial action registry.
Append `&blend=alpha-blend` or `&blend=additive` to preview the two
development-only agent contrast treatments. These are visual fixtures, not
claims that a camera or optical display is active.

Vite proxies `/api` HTTP and WebSocket traffic to
`http://127.0.0.1:4310` by default. Point it at another running Codori server
Expand All @@ -56,6 +61,19 @@ Users sensitive to motion or flicker should enable reduced effects before enteri

## Controls

Status window:

- The verified `menu` component on the left `htc-vive-focus` input profile toggles the window. Codori does not guess extra gamepad indices on other profiles, and WebXR-reserved app/system buttons may be absent.
- With a tracked left hand and no active left controller, hold the raised back-of-hand pose for `450 ms`. Lowering it for `180 ms` dismisses the window; short pose/tracking gaps use hysteresis and a `300 ms` tracking-loss grace.
- A controller-opened window remains open until reinvoked, an action is selected, or the left grip is held below the lowering threshold for `250 ms`.
- Placement uses the left grip or WebXR `wrist` joint plus a viewer-facing vertical offset. WebXR exposes no elbow joint, so this is a wrist/grip-and-view approximation rather than full forearm tracking.
- Right controller rays and direct controller contact can activate actions. Tracked hands require direct `index-finger-tip` contact; remote pinch/ray activation is rejected for status actions.
- When no exposed mapped menu component or gesture-eligible tracked left hand can invoke status, an in-canvas bottom-right `Menu` is shown. This includes right-hand-only tracking and controllers whose app/system button is reserved or unmapped; their target ray can select the fallback. A left controller takes precedence over the left-hand gesture, so an unmapped left controller keeps the fallback available for ray selection. A DOM-overlay button mirrors it when the optional DOM Overlays feature is granted. Both disappear when a mapped menu control or eligible left-hand gesture becomes usable; screen/gaze can open the menu but cannot bypass `controller-or-touch` action policy.

The status view uses a translucent pale lime treatment distinct from cyan panes. It shows the authoritative primary and secondary Codex quota windows, localized reset times, current-thread context remaining only when known, connection/voice state, pane count, and thread/workspace identity. Sparse `account/rateLimits/updated` buckets merge by `limitId` into the last `account/rateLimits/read` snapshot; unknown quota or context is labeled unavailable rather than rendered as zero/full.

Initial actions are `Passthrough`, `Recenter workspace`, the live voice/resume-audio action, `Reduced effects`, and `Exit immersive`. Each registry item carries a stable id, state, availability/disabled reason, callback, and input policy so additional actions can be added without changing the status surface architecture.

Controller:

- target ray: hover and select controls or panel content
Expand All @@ -75,11 +93,15 @@ Tracked hand:

Native and synthesized primary actions are de-duplicated. Competing grabs have one deterministic owner, and input-source loss releases hover/grab state.

`Recenter workspace` rotates and translates one shared anchor into the current horizontal gaze at clamped eye height. The agent light, transcript, status surfaces, automatic panes, and manually moved pane-local transforms move together without reallocating pane ids. A `local-floor` reference-space `reset` schedules exactly one anchor refresh; Codori does not emulate or require a reserved platform recenter button.

`Passthrough` is a real session-mode transition, not a background toggle. When `immersive-ar` is supported, Codori ends the current session and requests the other mode while retaining the same workspace and voice runtimes/subscriptions. If the browser cannot complete that transition within the action, the entry surface explains that state and offers explicit re-entry without recreating the RPC runtime. In a non-opaque AR session, transparent renderer pixels expose the environment and room geometry plus the boundary Exit door are hidden. `alpha-blend` uses a restrained dark stipple shell outside the agent light; `additive` uses a bright magenta shape outline because dark pixels cannot occlude an optical display. An AR session reporting `opaque` is never described as passthrough. The normal projection path remains correct without WebXR Layers.

Immersive entry starts the realtime voice session automatically after at least `500 ms` and as soon as the workspace runtime is ready. New XR sessions reuse the voice selection and browser-only voice-instruction override saved by Settings → Voice. The selected voice is sent only when the connected server advertises it; prompt precedence remains browser override, `experimental_realtime_ws_backend_prompt` from `config.toml`, then the Codori default. Selecting the central light stops the active session and re-arms the dormant visual state; selecting it again replays the full awakening before restarting voice. A door-sized rounded `Exit` surface sits on the `10 m × 10 m` room boundary, beyond the agent light along the initial view direction. The 2D fallback remains available before immersive entry.

While realtime startup is pending, the agent light remains dormant at `86%` scale, `72%` intensity, and nearly zero lens flare. Startup triggers a `160 ms` flare ignition followed by an `850 ms` settle into the current activity state. Reduced-effects mode uses a smaller scale excursion and lower flare peak.

Web Audio synthesizes a one-second agent-awakening cue whose low mechanical chord clusters and softer upper harmonics beat against each other while their pitch rises on an ease-out curve for `700 ms`, followed by a `300 ms` fade whose cubic curve preserves the initial resonance before dropping away. Panel appearance uses a separate `250 ms` cue that blends an immediate low body with delayed, beating high harmonics. Multiple panels created in one synchronization batch produce one softly amplified cue instead of overlapping sounds. Run `pnpm --filter @codori/webxr render:sfx-previews -- <output-directory>` to render listenable WAV previews from the same canonical sound plans.
Web Audio synthesizes a one-second agent-awakening cue whose low mechanical chord clusters and softer upper harmonics beat against each other while their pitch rises on an ease-out curve for `700 ms`, followed by a `300 ms` fade whose cubic curve preserves the initial resonance before dropping away. Panel appearance uses a separate `250 ms` cue that blends an immediate low body with delayed, beating high harmonics. The status window uses related `380 ms` pitch-up and `300 ms` pitch-down cues. Multiple panels created in one synchronization batch produce one softly amplified cue instead of overlapping sounds. Run `pnpm --filter @codori/webxr render:sfx-previews -- <output-directory>` to render listenable WAV previews from the same canonical sound plans.

## Panel semantics and caps

Expand All @@ -102,4 +124,4 @@ Growing output follows the live tail until manual scrolling. Later deltas preser

Automated tests cover session options/failure states, deterministic light bounds and reduced effects, transcript generation plus 30-second visibility and 250 ms scale transitions, streaming panel lifecycle plus 60-second dwell and 125 ms forced dismissal, panel retention/layout/scroll state, input ownership and source loss, shared notification adapters, `/xr/` server routing, and package builds.

Real headset validation is still required before making device-specific support or performance claims. Record the headset OS, browser version, optional features granted, target refresh rate, median frame time, sustained worst frame-time band, text legibility, and a 15-minute mixed voice/tool memory observation for each supported device/browser combination.
Real headset validation is still required before making device-specific support, transition, input-component, blend-mode, or performance claims. For each controller, hand-tracking, and screen/gaze device/browser combination, record headset/device, OS, browser version, supported session modes, actual `environmentBlendMode` and `interactionMode`, optional features granted, exposed input profiles/components, app-visible buttons, target refresh rate, median frame time, sustained worst frame-time band, status anchoring/direct-touch behavior, alpha/additive contrast readability, text legibility, and a 15-minute mixed voice/tool memory observation. Browser kitchen-sink QA proves only canvas layout, colors, typography, and animation; it cannot prove headset poses, camera passthrough, additive optics, reserved buttons, or seamless session switching.
1 change: 1 addition & 0 deletions packages/webxr/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ <h1 id="entry-title">Step into your coding session.</h1>
<canvas id="xr-canvas" aria-label="Immersive Codori scene" hidden></canvas>
<div id="scene-status" class="scene-status" role="status" aria-live="polite" hidden></div>
<div id="scene-controls" class="scene-controls" hidden>
<button id="fallback-menu" type="button" hidden>Menu</button>
<button id="exit-xr" type="button">Exit immersive</button>
</div>
</main>
Expand Down
4 changes: 3 additions & 1 deletion packages/webxr/scripts/render-sound-previews.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ await mkdir(outputDirectory, { recursive: true })

for (const [name, plan] of [
['agent-awakening.wav', soundEffectPlans.awakening],
['panel-appear.wav', soundEffectPlans.panelAppear]
['panel-appear.wav', soundEffectPlans.panelAppear],
['status-open.wav', soundEffectPlans.statusOpen],
['status-close.wav', soundEffectPlans.statusClose]
]) {
const outputPath = path.join(outputDirectory, name)
await writeFile(outputPath, wavBuffer(renderPlan(plan)))
Expand Down
29 changes: 28 additions & 1 deletion packages/webxr/src/billboard.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Matrix4, Quaternion, Vector3 } from 'three'
import {
Matrix4,
Quaternion,
Vector3,
type Object3D
} from 'three'

const lookAtMatrix = new Matrix4()
const targetQuaternion = new Quaternion()
const worldUp = new Vector3(0, 1, 0)
const billboardWorldPosition = new Vector3()
const billboardWorldQuaternion = new Quaternion()
const billboardParentQuaternion = new Quaternion()

export const viewerFacingQuaternion = (
objectPosition: Vector3,
Expand All @@ -26,3 +34,22 @@ export const smoothViewerFacingQuaternion = (
const progress = 1 - Math.exp(-Math.max(0, damping) * Math.max(0, deltaSeconds))
return current.slerp(target, progress)
}

export const viewerFacingLocalQuaternion = (
object: Object3D,
viewerPosition: Vector3,
target = new Quaternion()
) => {
object.getWorldPosition(billboardWorldPosition)
billboardWorldQuaternion.copy(viewerFacingQuaternion(
billboardWorldPosition,
viewerPosition
))
if (!object.parent) {
return target.copy(billboardWorldQuaternion)
}
object.parent.getWorldQuaternion(billboardParentQuaternion)
return target.copy(billboardParentQuaternion)
.invert()
.multiply(billboardWorldQuaternion)
}
Loading