fix: hold home's ai composer space with a matching placeholder (#11936)

* fix: hold home's ai composer space with a matching placeholder while it loads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: skip remembering the composer height when a draft holds it open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-22 01:46:37 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 01084cd405
commit 9202a74071
3 changed files with 161 additions and 21 deletions
@@ -24,6 +24,10 @@
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import { BROWSER } from 'esm-env'
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
import HomeComposerSkeleton, {
composerHeight,
rememberComposerHeight
} from './HomeComposerSkeleton.svelte'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -132,13 +136,23 @@
fork shares its parent's, and remounting for it only flashes the block. -->
{#if HomeSessionStarter && sessionState.hydrated && (rulesReady || shownForRoot === $currentWorkspaceRootId)}
{#key $currentWorkspaceRootId}
<HomeSessionStarter onShown={() => (shownForRoot = $currentWorkspaceRootId)} />
<HomeSessionStarter
onShown={() => (shownForRoot = $currentWorkspaceRootId)}
{placeholder}
onSettled={(height) => rememberComposerHeight($workspaceStore ?? undefined, height)}
/>
{/key}
{:else}
{@render placeholder()}
{/if}
{/if}
</div>
</div>
{#snippet placeholder()}
<HomeComposerSkeleton height={composerHeight($workspaceStore ?? undefined)} />
{/snippet}
<HomeConnectDrawer bind:this={homeConnectDrawer} />
<!-- Everything the hero offered beside the composer lives in the band's menu: the connect helper,
@@ -0,0 +1,102 @@
<script lang="ts" module>
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
const HEIGHT_SETTING = 'home-composer-height:'
// The box's 4px top margin and the 8px under the block.
const BLOCK_MARGINS = 12
const ONE_LINE_HEIGHT = 48 + BLOCK_MARGINS
// The suggestion chips' row under an open box. A disabled composer has none, and its box,
// the shortest open one, is 78px.
const SUGGESTIONS_ROW = 36
const OPEN_BOX_MIN = 78
/** The height Home's composer took when it last appeared in this workspace. */
export function composerHeight(workspace: string | undefined): number | undefined {
const stored = workspace ? Number(getLocalSetting(HEIGHT_SETTING + workspace)) : NaN
return Number.isFinite(stored) && stored > 0 ? stored : undefined
}
export function rememberComposerHeight(workspace: string | undefined, height: number) {
if (workspace && height > 0) storeLocalSetting(HEIGHT_SETTING + workspace, String(height))
}
</script>
<script lang="ts">
import { ArrowUp, MessagesSquare } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import Button from '../common/button/Button.svelte'
import {
composerBoxClass,
HERO_BOX_CLASS,
STARTER_PLACEHOLDER
} from '../copilot/chat/composerBox'
// Stands in for the composer while its code and config load, drawn as the composer it
// replaces: the same box, mark and placeholder, in the shape it last took in this workspace.
// Without a remembered height it is the one-line shape, which is what a workspace past a
// handful of items rests in.
let { height }: { height?: number } = $props()
const open = $derived(height !== undefined && height > ONE_LINE_HEIGHT)
const suggestions = $derived(open && height! - BLOCK_MARGINS - SUGGESTIONS_ROW >= OPEN_BOX_MIN)
const boxClass = twMerge(composerBoxClass(), HERO_BOX_CLASS)
</script>
{#snippet bar(width: string)}
<span class="animate-skeleton dark:bg-frost-900/50 rounded h-3 {width}"></span>
{/snippet}
{#snippet sendButton()}
<Button variant="subtle" unifiedSize="sm" iconOnly disabled startIcon={{ icon: ArrowUp }} />
{/snippet}
<div
class="w-full pb-2 flex flex-col"
style:height={open ? `${height}px` : undefined}
aria-busy="true"
aria-label="Loading the AI composer"
>
<!-- The placeholder's `-px` offsets stand for the real field's transparent 1px border. -->
{#if open}
<div class="mt-1 flex-1 min-h-0 flex flex-col {boxClass}">
<div class="flex items-start">
<span class="flex items-center h-5 mt-[13px] ml-4 shrink-0">
<MessagesSquare size={16} class="text-ai" />
</span>
<span class="mt-px ml-px pl-2 pr-4 pt-3 text-xs leading-5 text-gray-500 truncate">
{STARTER_PLACEHOLDER}
</span>
</div>
<div class="mt-auto flex flex-row items-center gap-1.5 px-3 pb-1.5">
<div class="flex-1 min-w-0 flex flex-row items-center gap-3">
{@render bar('w-24')}
{@render bar('w-20')}
</div>
{@render bar('w-12')}
{@render sendButton()}
</div>
</div>
{#if suggestions}
<div class="pt-2 flex flex-row items-center gap-1.5 shrink-0">
{#each ['w-20', 'w-24', 'w-24'] as width, i (i)}
<span class="h-7 px-3 flex items-center rounded-full border border-border-light">
{@render bar(width)}
</span>
{/each}
</div>
{/if}
{:else}
<div class="mt-1 h-12 flex flex-row items-center {boxClass}">
<span class="flex items-center ml-4 shrink-0">
<MessagesSquare size={16} class="text-ai" />
</span>
<span class="flex-1 min-w-0 ml-px pl-2 pr-2 text-xs leading-5 text-gray-500 truncate">
{STARTER_PLACEHOLDER}
</span>
<div class="flex flex-row items-center gap-1 pr-3 shrink-0">
{@render bar('w-12')}
{@render sendButton()}
</div>
</div>
{/if}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, setContext } from 'svelte'
import { onMount, setContext, type Snippet } from 'svelte'
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import type { DraftAttachments } from '$lib/components/copilot/chat/messageDraft.svelte'
import type { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
@@ -27,7 +27,14 @@
// Home's block is the empty session's own chat, run by that session's manager: the
// composer, its controls and what a send does are the session's, not a copy of them.
// Picked once; the parent remounts this on a workspace switch, which changes the family.
let { onShown }: { onShown?: () => void } = $props()
// `placeholder` stands in while the chat waits on its config; `onSettled` reports the height
// the chat first took, for the next placeholder to take.
let {
onShown,
placeholder,
onSettled
}: { onShown?: () => void; placeholder?: Snippet; onSettled?: (height: number) => void } =
$props()
const initialSession = homeStarterSession()
const sessionId = initialSession.id
@@ -70,6 +77,19 @@
}
let aiChat: AIChat | undefined = $state(undefined)
let chatWrapper: HTMLDivElement | undefined = $state(undefined)
// A seeded draft holds the composer open, which is not the shape it rests in once the
// draft is sent, so only a mount with an empty draft is measured.
const initialAttachments = getSessionDraftAttachments(sessionId)
let settled =
!!getSessionDraftPrompt(sessionId)?.trim() ||
(!!initialAttachments && Object.values(initialAttachments).some((lane) => lane.length > 0))
$effect(() => {
if (!aiChat || !chatWrapper || settled) return
settled = true
const wrapper = chatWrapper
requestAnimationFrame(() => onSettled?.(wrapper.offsetHeight))
})
onMount(() => {
onShown?.()
if (takeComposerFocus()) setTimeout(() => aiChat?.focusInput(), 0)
@@ -92,22 +112,26 @@
The item count decides whether the composer rests collapsed, so it waits for that too
rather than open and fold up a moment later. -->
{#if session && $copilotWorkspace !== undefined && starterPrompts.fewItems !== undefined}
<AIChat
bind:this={aiChat}
hideHeader
hideModeSelector
wideLayout
initialInstructions={getSessionDraftPrompt(sessionId)}
{onDraftChange}
initialAttachments={getSessionDraftAttachments(sessionId)}
{onAttachmentsChange}
starter={{
where: 'home',
suggestions: starterPrompts.suggestions,
fewItems: starterPrompts.fewItems
}}
viewTransitionNames
{onSent}
{actingOn}
/>
<div bind:this={chatWrapper}>
<AIChat
bind:this={aiChat}
hideHeader
hideModeSelector
wideLayout
initialInstructions={getSessionDraftPrompt(sessionId)}
{onDraftChange}
initialAttachments={getSessionDraftAttachments(sessionId)}
{onAttachmentsChange}
starter={{
where: 'home',
suggestions: starterPrompts.suggestions,
fewItems: starterPrompts.fewItems
}}
viewTransitionNames
{onSent}
{actingOn}
/>
</div>
{:else}
{@render placeholder?.()}
{/if}