diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index 5b353436404..f50f74be668 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -1,5 +1,6 @@ import { useStore } from '@nanostores/react' +import { useSessionView } from '@/app/chat/session-view' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' @@ -8,8 +9,7 @@ import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' -import { setCurrentSessionPreviewTarget } from '@/store/preview' -import { $currentCwd } from '@/store/session' +import { openPreview } from '@/store/preview' export function AttachmentList({ attachments, @@ -31,13 +31,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const { t } = useI18n() const c = t.composer const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] - const cwd = useStore($currentCwd) + // The tile's cwd when this pill lives in a tile composer, not the primary's: + // a relative attachment path has to resolve against its own session's root. + const cwd = useStore(useSessionView().$cwd) const isUploading = attachment.uploadState === 'uploading' const hasUploadError = attachment.uploadState === 'error' const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined - async function openPreview() { + async function openAttachmentPreview() { if (!canPreview) { return } @@ -70,7 +72,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme ? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const } : preview - setCurrentSessionPreviewTarget(withBytes, 'manual', target) + openPreview(withBytes, 'manual') } catch (error) { notifyError(error, c.previewUnavailable) } @@ -89,7 +91,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme : 'border-border/60 hover:border-primary/35 hover:bg-accent/45' )} disabled={!canPreview} - onClick={() => void openPreview()} + onClick={() => void openAttachmentPreview()} type="button" > diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index 949b8f1020c..aa657ca8291 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react' import { playSpeechText } from '@/lib/voice-playback' import { ownsAmbientCue } from '@/store/ambient' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $voicePlayback } from '@/store/voice-playback' import { $autoSpeakReplies } from '@/store/voice-prefs' +import { useComposerScope } from '../scope' + interface AutoSpeakReply { id: string pending: boolean @@ -40,6 +41,9 @@ export function useAutoSpeakReplies({ sessionId }: UseAutoSpeakReplies) { const enabled = useStore($autoSpeakReplies) + // Wake on THIS composer's transcript: a tile subscribed to the primary's + // would never fire on its own replies (and would fire on someone else's). + const { $messages } = useComposerScope() const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply }) latest.current = { conversationActive, failureLabel, markSpoken, pendingReply } @@ -83,5 +87,5 @@ export function useAutoSpeakReplies({ const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)] return () => stops.forEach(f => f()) - }, [enabled, sessionId]) + }, [$messages, enabled, sessionId]) } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index f8b75183f30..8e53096f322 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -5,11 +5,11 @@ import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' import { resetBrowseState } from '@/store/composer-input-history' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import type { ComposerTarget } from '../focus' import { onComposerVoiceToggleRequest } from '../focus' +import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' import { useAutoSpeakReplies } from './use-auto-speak-replies' @@ -50,6 +50,8 @@ export function useComposerVoice({ target }: UseComposerVoiceArgs) { const { t } = useI18n() + // A tile's composer speaks ITS transcript, not the primary chat's. + const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 6ce8289fa0a..2c65061cde6 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,6 +1,14 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react' +import { + type ClipboardEvent, + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef +} from 'react' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' @@ -11,7 +19,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $compactionActive } from '@/store/compaction' +import { sessionCompacting } from '@/store/compaction' import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history' import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue' @@ -108,7 +116,7 @@ export function ChatBar({ // focus-bus key, and awaiting-input edge. Main scope = the legacy globals. const scope = useComposerScope() const attachments = useStore(scope.attachments.$attachments) - const compacting = useStore($compactionActive) + const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId])) const scrolledUp = useStore($threadScrolledUp) const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must @@ -637,7 +645,7 @@ export function ChatBar({ // $messages is read imperatively (not subscribed) so the composer // doesn't re-render on every streaming delta flush. - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const entry = browseBackward(sessionId, currentDraft, history) if (entry !== null) { @@ -662,7 +670,7 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const result = browseForward(sessionId, history) if (result !== null) { diff --git a/apps/desktop/src/app/chat/composer/scope.tsx b/apps/desktop/src/app/chat/composer/scope.tsx index 67581a39e30..53b43107507 100644 --- a/apps/desktop/src/app/chat/composer/scope.tsx +++ b/apps/desktop/src/app/chat/composer/scope.tsx @@ -25,18 +25,19 @@ export interface ComposerScope { attachments: ComposerAttachmentScope /** Only the main scope may pop out (the floating composer is a singleton). */ popoutAllowed: boolean - /** Imperative read of this scope's transcript (input-history browse) — - * never subscribed, so streaming stays out of the composer's renders. */ - readMessages: () => ChatMessage[] + /** This scope's transcript. Read it imperatively (input-history browse) to + * keep streaming out of the composer's renders; subscribe only off-render + * (auto-speak) where the reply edge is the whole point. */ + $messages: ReadableAtom /** Focus-bus routing key (`'main'` | `'tile:'`). */ target: ComposerTarget } export const MAIN_COMPOSER_SCOPE: ComposerScope = { $awaitingInput: $activeSessionAwaitingInput, + $messages, attachments: mainComposerScope, popoutAllowed: true, - readMessages: () => $messages.get(), target: 'main' } diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index ae618e344e6..b64f7ca55a4 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -126,9 +126,9 @@ function TileChat({ const scope = useMemo( () => ({ $awaitingInput: sessionAwaitingInput(runtimeId), + $messages: view.$messages, attachments, popoutAllowed: false, - readMessages: () => view.$messages.get(), target: `tile:${storedSessionId}` }), [attachments, runtimeId, storedSessionId, view.$messages] diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index 5b9a5837c26..1fb80b54907 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -1,7 +1,8 @@ import { useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type FC, type ReactNode, useEffect, useState } from 'react' +import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react' +import { useSessionView } from '@/app/chat/session-view' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { Codicon } from '@/components/ui/codicon' @@ -9,9 +10,9 @@ import { Loader } from '@/components/ui/loader' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { $backgroundResume } from '@/store/background-delegation' -import { $compactionActive } from '@/store/compaction' -import { $activeSessionAwaitingInput } from '@/store/prompts' -import { $activeSessionId, $turnStartedAt } from '@/store/session' +import { sessionCompacting } from '@/store/compaction' +import { sessionAwaitingInput } from '@/store/prompts' +import { $turnStartedAt } from '@/store/session' const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, @@ -37,11 +38,23 @@ const CompactionHint: FC = () => ( {COMPACTION_LABEL} ) -function useActiveTurnTimerKey(): string | undefined { - const activeSessionId = useStore($activeSessionId) +/** These indicators render inside whichever transcript mounted them, so every + * session-scoped signal comes from that surface's view — a tile must never + * show the primary chat's compaction, prompt-wait, or turn timer. */ +function useThreadSessionStatus() { + const sessionId = useStore(useSessionView().$runtimeId) const turnStartedAt = useStore($turnStartedAt) + const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId])) + // A pending clarify / approval / sudo / secret means the turn is paused on the + // user, not working — so don't resurrect the "thinking" timer while they + // decide (matches the pet's awaitingInput pose taking priority over busy). + const awaitingInput = useStore(useMemo(() => sessionAwaitingInput(sessionId), [sessionId])) - return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined + return { + awaitingInput, + compacting, + turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined + } } export const CenteredThreadSpinner: FC = () => { @@ -67,9 +80,8 @@ export const CenteredThreadSpinner: FC = () => { export const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() - const timerKey = useActiveTurnTimerKey() - const elapsed = useElapsedSeconds(true, timerKey) - const compacting = useStore($compactionActive) + const { compacting, turnTimerKey } = useThreadSessionStatus() + const elapsed = useElapsedSeconds(true, turnTimerKey) return ( { // what lets the timer read "quiet for 12s" rather than the age of this // component, which is the whole turn so far. const [quietSince, setQuietSince] = useState(undefined) - const compacting = useStore($compactionActive) - const turnTimerKey = useActiveTurnTimerKey() - // A pending clarify / approval / sudo / secret means the turn is paused on the - // user, not working — so don't resurrect the "thinking" timer while they - // decide (matches the pet's awaitingInput pose taking priority over busy). - const awaitingInput = useStore($activeSessionAwaitingInput) + const { awaitingInput, compacting, turnTimerKey } = useThreadSessionStatus() useEffect(() => { setQuietSince(undefined) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-preview-scope.test.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback-preview-scope.test.tsx new file mode 100644 index 00000000000..19c0868be32 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-preview-scope.test.tsx @@ -0,0 +1,76 @@ +import { cleanup, render } from '@testing-library/react' +import { atom } from 'nanostores' +import type { ComponentProps, ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { type SessionView, SessionViewProvider } from '@/app/chat/session-view' +import { $previewStatusBySession } from '@/store/preview-status' +import { $activeSessionId, $currentCwd } from '@/store/session' + +vi.mock('@assistant-ui/react', async importOriginal => ({ + ...(await importOriginal>()), + useAuiState: (select: (state: unknown) => unknown) => + select({ message: { id: 'msg-1', status: { type: 'complete' } }, thread: { isRunning: false } }) +})) + +const { ToolFallback } = await import('./fallback') + +const PRIMARY_ID = 'primary-session' +const TILE_ID = 'tile-session' + +/** Minimal tile view: only the fields the tool row reads. */ +function tileView(): SessionView { + return { + ...({} as SessionView), + $cwd: atom('/tile/work'), + $messages: atom([]), + $runtimeId: atom(TILE_ID), + kind: 'tile' + } +} + +function renderToolRow(wrap: (node: ReactNode) => ReactNode) { + const props = { + args: { path: '/tile/work/report.html' }, + result: { path: '/tile/work/report.html' }, + toolCallId: 'call-1', + toolName: 'write_file' + } as unknown as ComponentProps + + render(<>{wrap()}) +} + +afterEach(() => { + cleanup() + $previewStatusBySession.set({}) + $activeSessionId.set(null) + $currentCwd.set('') +}) + +describe('tool row preview recording', () => { + // The row used to record under the global (primary-only) $activeSessionId, so + // a preview produced inside a session TILE surfaced in the main chat's + // composer instead of the tile's own. + it('records into the session whose transcript the row is in, not the primary', () => { + $activeSessionId.set(PRIMARY_ID) + $currentCwd.set('/primary/work') + + const view = tileView() + + renderToolRow(node => {node}) + + const recorded = $previewStatusBySession.get() + + expect(Object.keys(recorded)).toEqual([TILE_ID]) + expect(recorded[TILE_ID]?.[0]?.cwd).toBe('/tile/work') + }) + + it('still records into the primary session for the main chat', () => { + $activeSessionId.set(PRIMARY_ID) + $currentCwd.set('/primary/work') + + renderToolRow(node => node) + + expect(Object.keys($previewStatusBySession.get())).toEqual([PRIMARY_ID]) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 746a64dccdf..13e7c7e8e7a 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -16,6 +16,7 @@ import { useState } from 'react' +import { useSessionView } from '@/app/chat/session-view' import { AnsiText } from '@/components/assistant-ui/ansi-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' @@ -39,7 +40,6 @@ import { normalize } from '@/lib/text' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { recordPreviewArtifact } from '@/store/preview-status' -import { $activeSessionId, $currentCwd } from '@/store/session' import { $toolInlineDiff } from '@/store/tool-diffs' import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' @@ -364,9 +364,12 @@ function ToolEntry({ part }: ToolEntryProps) { // Surface a previewable artifact (HTML file / localhost URL) as a compact link // in the composer status stack rather than a bulky inline card. Uses the same - // detected target the old inline card did, keyed to the active session the - // stack reads from. Idempotent + dedup'd, so re-renders don't churn. + // detected target the old inline card did. Idempotent + dedup'd, so re-renders + // don't churn. const previewTarget = view.previewTarget + // The session whose transcript this row is IN, which is not necessarily the + // primary one: a tool row inside a session tile must feed that tile's composer. + const { $cwd: $sessionCwd, $runtimeId: $sessionRuntimeId } = useSessionView() useEffect(() => { if (isPending || !previewTarget || !isPreviewableTarget(previewTarget)) { @@ -376,12 +379,12 @@ function ToolEntry({ part }: ToolEntryProps) { // Read (don't subscribe) session/cwd: this only fires when a previewable // target appears, and subscribing re-rendered every tool row on any session // or cwd change. - const activeSessionId = $activeSessionId.get() + const sessionId = $sessionRuntimeId.get() - if (activeSessionId) { - recordPreviewArtifact(activeSessionId, previewTarget, $currentCwd.get() || '') + if (sessionId) { + recordPreviewArtifact(sessionId, previewTarget, $sessionCwd.get() || '') } - }, [isPending, previewTarget]) + }, [$sessionCwd, $sessionRuntimeId, isPending, previewTarget]) const detailSections = useMemo(() => { if (!view.detail) { diff --git a/apps/desktop/src/components/chat/preview-attachment.tsx b/apps/desktop/src/components/chat/preview-attachment.tsx index a9e02a3d3e4..8a166356578 100644 --- a/apps/desktop/src/components/chat/preview-attachment.tsx +++ b/apps/desktop/src/components/chat/preview-attachment.tsx @@ -1,33 +1,28 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' +import { useSessionView } from '@/app/chat/session-view' import { useI18n } from '@/i18n' import { MonitorPlay } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { previewName } from '@/lib/preview-targets' import { notifyError } from '@/store/notifications' -import { - $previewTarget, - dismissPreviewTarget, - type PreviewRecordSource, - setCurrentSessionPreviewTarget -} from '@/store/preview' -import { $currentCwd } from '@/store/session' +import { $previewTabSources, closePreviewForSource, openPreview, type PreviewRecordSource } from '@/store/preview' export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) { const { t } = useI18n() - const cwd = useStore($currentCwd) - const activePreview = useStore($previewTarget) + // This link lives in one session's transcript; resolve it against THAT + // session's cwd, not the primary chat's. + const cwd = useStore(useSessionView().$cwd) + const openSources = useStore($previewTabSources) const [opening, setOpening] = useState(false) - const activePreviewRef = useRef(activePreview) const cwdRef = useRef(cwd) const mountedRef = useRef(false) const requestTokenRef = useRef(0) const targetRef = useRef(target) const name = previewName(target) - const isActive = activePreview?.source === target + const isActive = openSources.includes(target) - activePreviewRef.current = activePreview cwdRef.current = cwd targetRef.current = target @@ -53,7 +48,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev } if (isActive) { - dismissPreviewTarget() + closePreviewForSource(target) return } @@ -80,13 +75,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev throw new Error(`Could not open preview target: ${requestTarget}`) } - const currentPreview = activePreviewRef.current - - if (currentPreview?.source === preview.source && currentPreview.url === preview.url) { - return - } - - setCurrentSessionPreviewTarget(preview, source, requestTarget) + openPreview(preview, source) } catch (error) { if ( !mountedRef.current || diff --git a/apps/desktop/src/store/compaction.test.ts b/apps/desktop/src/store/compaction.test.ts index f8388a3afc0..46aef98f91c 100644 --- a/apps/desktop/src/store/compaction.test.ts +++ b/apps/desktop/src/store/compaction.test.ts @@ -1,18 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction' -import { $activeSessionId } from './session' +import { $compactingSessions, sessionCompacting, setSessionCompacting } from './compaction' describe('compaction store', () => { - beforeEach(() => { - $compactingSessions.set({}) - $activeSessionId.set(null) - }) + beforeEach(() => $compactingSessions.set({})) - afterEach(() => { - $compactingSessions.set({}) - $activeSessionId.set(null) - }) + afterEach(() => $compactingSessions.set({})) it('tracks compaction per session independently', () => { setSessionCompacting('session-a', true) @@ -21,16 +14,11 @@ describe('compaction store', () => { expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true }) }) - it('exposes only the active session via the focus-scoped view', () => { + it('scopes the view to the session asked for, not whichever is active', () => { setSessionCompacting('session-a', true) - expect($compactionActive.get()).toBe(false) - - $activeSessionId.set('session-a') - expect($compactionActive.get()).toBe(true) - - $activeSessionId.set('session-b') - expect($compactionActive.get()).toBe(false) + expect(sessionCompacting('session-a').get()).toBe(true) + expect(sessionCompacting('session-b').get()).toBe(false) }) it('clears a session without disturbing the others', () => { diff --git a/apps/desktop/src/store/compaction.ts b/apps/desktop/src/store/compaction.ts index b35e35b4b69..cbe067ee3a0 100644 --- a/apps/desktop/src/store/compaction.ts +++ b/apps/desktop/src/store/compaction.ts @@ -1,7 +1,5 @@ import { atom, computed } from 'nanostores' -import { $activeSessionId } from './session' - // Per-session flag while auto-compaction runs mid-turn. Without it the // transcript looks like it reset; per-session so a background chat can't // clobber the foreground view. @@ -9,10 +7,11 @@ const keyFor = (sessionId: string | null | undefined): string => sessionId ?? '' export const $compactingSessions = atom>({}) -export const $compactionActive = computed( - [$compactingSessions, $activeSessionId], - (sessions, activeId) => keyFor(activeId) in sessions -) +/** Is `sessionId` compacting? Per-session because a transcript may be a tile, + * and a tile must never wear the primary chat's compaction state. */ +export function sessionCompacting(sessionId: null | string) { + return computed($compactingSessions, sessions => keyFor(sessionId) in sessions) +} export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void { const key = keyFor(sessionId)