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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
- Never use rounded corners in UI styling; keep corners square unless the user explicitly requests otherwise for a specific change.
- Document any new styling conventions or directory additions in this file so future changes remain consistent.

## Coding Principles
Expand Down
33 changes: 24 additions & 9 deletions packages/ui/src/components/context-meter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface ContextMeterProps {
usedLabel: string
availableLabel: string
class?: string
centerValue?: boolean
}

const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
Expand Down Expand Up @@ -104,18 +105,32 @@ export const ContextMeter: Component<ContextMeterProps> = (props) => {

const tooltipText = () => `Context Used: ${percentLabel()}`

const valuePill = () => (
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
)

if (props.centerValue) {
return (
<div class="grid grid-cols-[22px_auto_22px] items-center gap-2" title={tooltipText()}>
<div class="flex justify-end">{circle()}</div>
{valuePill()}
<span aria-hidden="true" />
</div>
)
}

return (
<div class="inline-flex items-center gap-2" title={tooltipText()}>
{circle()}
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
{valuePill()}
</div>
)
}
Expand Down
105 changes: 80 additions & 25 deletions packages/ui/src/components/instance-tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Dynamic } from "solid-js/web"
import {
DragDropProvider,
Expand Down Expand Up @@ -37,15 +37,9 @@ interface SortableAppTabProps {
onClose: (tabId: string) => void
}

const SortableAppTab: Component<SortableAppTabProps> = (props) => {
const sortable = createSortable(props.tab.id)

const AppTabContent: Component<SortableAppTabProps> = (props) => {
return (
<div
ref={sortable}
class={`tab-draggable ${sortable.isActiveDraggable ? "tab-draggable-active" : ""}`}
data-app-tab-id={props.tab.id}
>
<>
{props.tab.kind === "instance" ? (
<InstanceTab
instance={props.tab.instance}
Expand Down Expand Up @@ -82,14 +76,59 @@ const SortableAppTab: Component<SortableAppTabProps> = (props) => {
</button>
</div>
)}
</>
)
}

const SortableAppTab: Component<SortableAppTabProps> = (props) => {
const sortable = createSortable(props.tab.id)

return (
<div
ref={sortable}
class={`tab-draggable ${sortable.isActiveDraggable ? "tab-draggable-active" : ""}`}
data-app-tab-id={props.tab.id}
>
<AppTabContent {...props} />
</div>
)
}

const StaticAppTab: Component<SortableAppTabProps> = (props) => {
return (
<div class="tab-draggable" data-app-tab-id={props.tab.id}>
<AppTabContent {...props} />
</div>
)
}

const isTouchOnlyPointer = () => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches && !window.matchMedia?.("(any-pointer: fine)")?.matches)
}

const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const tabIds = createMemo(() => props.tabs.map((tab) => tab.id))
const [dragReorderEnabled, setDragReorderEnabled] = createSignal(!isTouchOnlyPointer())

onMount(() => {
if (typeof window === "undefined") return
const coarseQuery = window.matchMedia?.("(pointer: coarse)")
const fineQuery = window.matchMedia?.("(any-pointer: fine)")
if (!coarseQuery || !fineQuery) return

const syncDragReorder = () => setDragReorderEnabled(!isTouchOnlyPointer())
syncDragReorder()
coarseQuery.addEventListener("change", syncDragReorder)
fineQuery.addEventListener("change", syncDragReorder)

onCleanup(() => {
coarseQuery.removeEventListener("change", syncDragReorder)
fineQuery.removeEventListener("change", syncDragReorder)
})
})

/** Whether to show toast history panel */
const [showToastHistory, setShowToastHistory] = createSignal(false)
Expand Down Expand Up @@ -132,22 +171,38 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleDragEnd}>
<DragDropSensors>
<SortableProvider ids={tabIds()}>
<For each={props.tabs}>
{(tab) => (
<SortableAppTab
tab={tab}
activeTabId={props.activeTabId}
onSelect={props.onSelect}
onClose={props.onClose}
/>
)}
</For>
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
<Show
when={dragReorderEnabled()}
fallback={
<For each={props.tabs}>
{(tab) => (
<StaticAppTab
tab={tab}
activeTabId={props.activeTabId}
onSelect={props.onSelect}
onClose={props.onClose}
/>
)}
</For>
}
>
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleDragEnd}>
<DragDropSensors>
<SortableProvider ids={tabIds()}>
<For each={props.tabs}>
{(tab) => (
<SortableAppTab
tab={tab}
activeTabId={props.activeTabId}
onSelect={props.onSelect}
onClose={props.onClose}
/>
)}
</For>
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
</Show>
</div>
<div class="tab-strip-spacer" />
<Show when={props.tabs.length > 1}>
Expand Down
138 changes: 94 additions & 44 deletions packages/ui/src/components/instance/instance-shell2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import PermissionApprovalModal from "../permission-approval-modal"
import SessionView from "../session/session-view"
import MessageSection from "../message-section"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../action-overflow-menu"
import { formatTokenTotal } from "../../lib/formatters"
import ContextMeter from "../context-meter"
import { sseManager } from "../../lib/sse-manager"
Expand Down Expand Up @@ -156,8 +157,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})

const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
const narrowHeaderLayout = createMemo(() => sessionCenterWidthStep() === "narrow")
const compactHeaderLayout = createMemo(() => narrowHeaderLayout() || compactHeaderQuery())
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const showCompactFullscreenButton = createMemo(() => isPhoneLayout() && !props.mobileFullscreenMode)
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
Expand Down Expand Up @@ -539,6 +542,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.dispatchEvent(new CustomEvent(OPEN_SESSION_SEARCH_EVENT))
}

const narrowHeaderMenuItems = createMemo<ActionOverflowMenuItem[]>(() => {
const PreviewIcon = PreviewToggleIcon()
return [
{
key: "search",
label: t("instanceShell.chatSearch.openAriaLabel"),
icon: <Search class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: handleChatSearchClick,
},
{
key: "preview",
label: previewToggleLabel(),
icon: <PreviewIcon class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: handlePreviewButtonClick,
},
]
})

const openBackgroundOutput = (process: BackgroundProcess) => {
setSelectedBackgroundProcess(process)
setShowBackgroundOutput(true)
Expand Down Expand Up @@ -990,15 +1011,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
when={!compactHeaderLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
{renderHeaderLeftSlot()}

<div class="flex-1 flex items-center justify-center min-w-0">
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-2 w-full">
<div class="flex min-w-0 items-center gap-2">
{renderHeaderLeftSlot()}
{renderSessionHeaderIndicators()}
</div>

<div class="flex flex-wrap items-center justify-center gap-1">
<Show when={!showingInfoView()}>
<Show when={!showingInfoView() && !narrowHeaderLayout()}>
<IconButton
color="inherit"
onClick={handleChatSearchClick}
Expand All @@ -1023,55 +1043,85 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span>
</div>

<div class="flex-1 flex items-center justify-center min-w-0">
<div class="flex flex-1 items-center justify-end gap-1 min-w-0">
<span
class={`status-indicator ${connectionStatusClass()}`}
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
>
<span class="status-dot" />
</span>
</div>

<Show when={!isPhoneLayout()}>
{renderPreviewToggleButton()}
</Show>
<Show when={!isPhoneLayout() && !narrowHeaderLayout()}>
{renderPreviewToggleButton()}
</Show>

<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
{renderPreviewToggleButton()}
</Show>
<Show when={showCompactFullscreenButton() && !narrowHeaderLayout()}>
{renderPreviewToggleButton()}
</Show>

<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
>
{rightAppBarButtonIcon()}
</IconButton>
</Show>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
>
{rightAppBarButtonIcon()}
</IconButton>
</Show>
</div>
</div>

<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<Show when={!showingInfoView()}>
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
<div
class={
narrowHeaderLayout() || showCompactFullscreenButton()
? "grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 pb-1"
: "flex flex-wrap items-center justify-center gap-2 pb-1"
}
>
<Show when={narrowHeaderLayout() || showCompactFullscreenButton()}>
<div class="flex min-w-0 items-center justify-start">
<Show when={narrowHeaderLayout() && !showingInfoView()}>
<ActionOverflowMenu
items={narrowHeaderMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={1}
/>
</Show>
</div>
</Show>

<div class="flex items-center justify-center">
<Show when={!showingInfoView()}>
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
centerValue={narrowHeaderLayout() || showCompactFullscreenButton()}
/>
</Show>
</div>

<Show when={narrowHeaderLayout() || showCompactFullscreenButton()}>
<div class="flex items-center justify-end gap-1">
<Show when={showCompactFullscreenButton()}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
sx={{ width: 30, height: 30 }}
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
</Show>
</div>
</Show>
</div>
</div>
Expand Down
Loading
Loading