feat: name the previewed item in an empty AI session's composer (#12021)

* feat: name the previewed item in an empty session's composer instead of greeting

* feat: name the item's kind in the empty session's composer placeholder

* fix: name the previewed item as its tab does, so view tabs get the summary
This commit is contained in:
Guilhem
2026-10-26 16:27:44 +01:00
committed by GitHub
parent 2b4dfccb36
commit 6939454edb
2 changed files with 56 additions and 16 deletions
@@ -79,17 +79,23 @@
import { goto } from '$lib/navigation'
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
import { StarterPrompts } from '$lib/components/copilot/chat/starter/starterPrompts.svelte'
import { resolvePreviewTab } from './previewRouter'
import { whereIs } from './sessionPreviewTabs.svelte'
import type { SessionPreviewTab } from './sessionState.svelte'
let {
sessionId,
headerInPage = false,
covered = false
covered = false,
previewTabName
}: {
sessionId: string
/** True for the session the page is showing: its header rides the page header bar. */
headerInPage?: boolean
/** True while the page holds this wrapper inert under an overlay, where nothing takes focus. */
covered?: boolean
/** The name the preview's tab strip shows for a tab, without its side marker. */
previewTabName: (tab: SessionPreviewTab) => string
} = $props()
// Parent keys by sessionId; this wrapper only mounts when the session exists.
@@ -117,8 +123,25 @@
// Whether the first message goes out in Use. Not `permissions.readOnly`: before that send a
// staged fork's session reads the workspace it forks, while the message builds in the fork.
const heldInUse = $derived(!!workModeLock.current || runtime?.manager.workMode === AIWorkMode.USE)
// The item an unsent session opened on ("Open in AI session", or a tab opened before the
// first send): the chat reads the displayed tab with each message, so the empty chat asks
// about that item instead of greeting. What is on screen only: a collapsed panel hides it.
// Named as its tab is, so a deployed item in View mode gets its summary from the listing.
const previewedItem = $derived.by(() => {
if (!opensSession) return undefined
const tab = runtime?.previewTabs.displayedTab
if (!tab) return undefined
const slot = resolvePreviewTab(whereIs(tab))
if (slot.kind !== 'editor' && slot.kind !== 'viewer') return undefined
const kind = slot.kind === 'editor' ? slot.editorKind : slot.viewerKind
return {
kind: kind === 'raw_app' ? 'app' : kind,
name: previewTabName(tab)
}
})
const greets = $derived(opensSession && !previewedItem)
const starterPrompts = new StarterPrompts(
() => (opensSession ? (actingWorkspaceId ?? $workspaceStore ?? undefined) : undefined),
() => (greets ? (actingWorkspaceId ?? $workspaceStore ?? undefined) : undefined),
() => !heldInUse
)
@@ -402,6 +425,13 @@
// utilities on one element resolve by stylesheet order, not attribute order.
const bannerClass =
'flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md'
const previewedItemPlaceholder = $derived(
previewedItem &&
(heldInUse
? `Ask about the ${previewedItem.kind} ${previewedItem.name}, or run it…`
: `Ask about the ${previewedItem.kind} ${previewedItem.name}, or describe a change…`)
)
</script>
{#if !session || !runtime}
@@ -409,7 +439,12 @@
{:else}
<!-- A sent session whose transcript is still loading (or came back empty) shows nothing
rather than the docked copilot's keyboard-shortcut hint, which means nothing here. -->
{#snippet sessionEmptyHint()}{/snippet}
{#snippet sessionEmptyHint()}
{#if previewedItem}
<!-- Holds the composer at the bottom, as in a session under way. -->
<div class="flex-1 min-h-0"></div>
{/if}
{/snippet}
{#snippet inputPreface()}
{#if showRecoveryNotice}
@@ -583,14 +618,14 @@
onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)}
initialAttachments={armedAtInit ? undefined : getSessionDraftAttachments(sessionId)}
onAttachmentsChange={(a) => setSessionDraftAttachments(sessionId, a)}
placeholder={composerPlaceholder}
placeholder={previewedItemPlaceholder ?? composerPlaceholder}
forceDisabled={isUnavailable || !!session.archived}
forceDisabledMessage={isUnavailable
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
: session.archived
? 'This session is archived. Unarchive it from the banner above to keep working.'
: ''}
starter={opensSession
starter={greets
? { where: 'session', suggestions: starterPrompts.suggestions }
: undefined}
viewTransitionNames={headerInPage && (opensSession || arrivingFromHomeSend())}
@@ -604,37 +639,37 @@
its transcript area, its column, and its footer row's height; for a session
that has never sent, the greeting above the centred composer. -->
<div
class="flex flex-col h-full {opensSession ? 'justify-center' : ''}"
class="flex flex-col h-full {greets ? 'justify-center' : ''}"
{@attach () => () =>
runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, false)}
>
{#if opensSession}
{#if greets}
<div class="flex-1 min-h-0 flex flex-col justify-end">
<div class="w-full max-w-3xl mx-auto px-7">
<SessionGreeting />
</div>
</div>
{:else if runtime.manager.displayMessages.length > 0}
{:else if previewedItem || runtime.manager.displayMessages.length > 0}
<div class="flex-1 min-h-0"></div>
{/if}
<div class="w-full max-w-3xl mx-auto px-7 pb-2">
{@render inputPreface()}
<ComposerPlaceholder
bind:value={() => draftText, setPlaceholderText}
toolbar={opensSession ? placeholderToolbar : undefined}
placeholder={opensSession
toolbar={greets ? placeholderToolbar : undefined}
placeholder={greets
? heldInUse
? USE_STARTER_PLACEHOLDER
: STARTER_PLACEHOLDER
: runtime.manager.displayMessages.length > 0
? 'Ask followup'
: composerPlaceholder}
: (previewedItemPlaceholder ?? composerPlaceholder)}
disabled={isUnavailable || !!session.archived}
autofocus={sessionState.currentSessionId === sessionId && !covered}
onSelectionChange={(selection) => (handoff = selection ?? null)}
onCompositionChange={(composing) => (placeholderComposing = composing)}
/>
{#if !opensSession}
{#if !greets}
<div class="mt-1 h-5"></div>
{:else if !isUnavailable && !session.archived && starterPrompts.suggestions.length > 0}
<div class="pt-2">
@@ -646,7 +681,7 @@
</div>
{/if}
</div>
{#if opensSession}
{#if greets}
<div class="flex-1 min-h-0"></div>
{/if}
</div>
@@ -852,13 +852,16 @@
// cell reactively, so the editor mirrors the name onto the tab model). Before
// that, the workspace listing names it. Falls back to the plain location label
// for summary-less items and non-item pages.
function tabLabelFor(tab: SessionPreviewTab, workspace: string): string {
function tabNameFor(tab: SessionPreviewTab, workspace: string): string {
const listed = listedItemFor(tab, workspace)
const name =
return (
tab.friendlyLabel ??
(listed && itemDisplayName(listed.path, listed.draftPath, listed.summary)) ??
previewLocationLabel(tab.loc)
return name + sideSuffix(tab.url)
)
}
function tabLabelFor(tab: SessionPreviewTab, workspace: string): string {
return tabNameFor(tab, workspace) + sideSuffix(tab.url)
}
// Hover title for a tab. A summary label is free text the strip truncates, and
@@ -1118,6 +1121,8 @@
sessionId={s.id}
headerInPage={s.id === activeSession?.id}
covered={aiHiddenVerdict !== false}
previewTabName={(tab) =>
tabNameFor(tab, getEffectiveWorkspaceId(s) ?? $workspaceStore ?? '')}
/>
</div>
{/each}