Skip to content
Open
207 changes: 207 additions & 0 deletions src/components/appearance-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import {
STORAGE_KEY_ZOOM_LEVEL,
STORAGE_KEY_CHAT_ANIMATIONS,
STORAGE_KEY_CHAT_CONTENT_WIDTH,
STORAGE_KEY_FLOATING_BALL_ENABLED,
STORAGE_KEY_FLOATING_BALL_POSITION,
STORAGE_KEY_FLOATING_BALL_BINDINGS,
STORAGE_KEY_FLOATING_BALL_SIZE,
STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS,
STORAGE_KEY_WELCOME_QUICK_ACTIONS,
STORAGE_KEY_UI_FONT,
STORAGE_KEY_UI_FONT_CUSTOM,
Expand Down Expand Up @@ -116,6 +121,35 @@ function syncAppearanceMode(mode: string) {

export type FontSelection = { id: string; custom: string }

/** 悬浮球停靠位置(视口坐标 px,左上角原点)。 */
export type FloatingBallPosition = { x: number; y: number }

/** 缺省停靠:右下角,距右边与底边各 18px(底边让出安全区)。 */
export const FLOATING_BALL_INSET = 18

/**
* 手势→动作绑定覆盖表。value 为 null 表示用户显式清除该手势(必须与"缺 key
* 回退默认"区分,否则清除功能失效);未出现的 key 一律取当前默认表。
*/
export type FloatingBallBindingsOverride = Partial<
Record<string, string | null>
>

/** 球体三档大小:默认大(46px),中 40、小 36。 */
export type FloatingBallSize = "small" | "medium" | "large"

export const FLOATING_BALL_SIZE_PX: Record<FloatingBallSize, number> = {
small: 36,
medium: 40,
large: 46,
}

export const DEFAULT_FLOATING_BALL_SIZE: FloatingBallSize = "large"

function isFloatingBallSize(v: unknown): v is FloatingBallSize {
return v === "small" || v === "medium" || v === "large"
}

type AppearanceContextValue = {
themeColor: ThemeColor
setThemeColor: (color: ThemeColor) => void
Expand All @@ -127,6 +161,21 @@ type AppearanceContextValue = {
/** 聊天区域动画总开关,默认开启;关闭时 <html> 带 data-chat-animations="off" */
chatAnimations: boolean
setChatAnimations: (on: boolean) => void
/** 悬浮球(手势入口)是否显示,默认关闭。 */
floatingBallEnabled: boolean
setFloatingBallEnabled: (on: boolean) => void
/** 悬浮球停靠位置;null = 右下角缺省位。 */
floatingBallPosition: FloatingBallPosition | null
setFloatingBallPosition: (position: FloatingBallPosition | null) => void
/** 手势绑定覆盖表(null = 用户清除);缺 key 回退默认表。 */
floatingBallBindings: FloatingBallBindingsOverride
setFloatingBallBindings: (bindings: FloatingBallBindingsOverride) => void
/** 球体三档大小。 */
floatingBallSize: FloatingBallSize
setFloatingBallSize: (size: FloatingBallSize) => void
/** 常用 agent 快切列表(agentType 数组,2-3 个)。 */
floatingBallFavoriteAgents: string[]
setFloatingBallFavoriteAgents: (agents: string[]) => void
/**
* 聊天内容宽度(100% 缩放下的 px,落到 CSS 时换成 rem,随缩放变化),
* null = 内置默认(48rem)。拖拽手柄与外观设置页共用;set 会持久化并落到
Expand Down Expand Up @@ -277,6 +326,69 @@ function readBool(key: string, def: boolean): boolean {
}
}

function readFloatingBallPosition(): FloatingBallPosition | null {
if (typeof document === "undefined") return null
try {
const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_POSITION)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== "object" || parsed === null) return null
const { x, y } = parsed as Record<string, unknown>
if (
typeof x !== "number" ||
typeof y !== "number" ||
!Number.isFinite(x) ||
!Number.isFinite(y)
) {
return null
}
return { x, y }
} catch {
return null
}
}

function readFloatingBallBindings(): FloatingBallBindingsOverride {
if (typeof document === "undefined") return {}
try {
const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_BINDINGS)
if (!raw) return {}
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== "object" || parsed === null) return {}
const out: FloatingBallBindingsOverride = {}
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
// null survives on purpose: it means "user cleared this gesture".
if (v === null || typeof v === "string") out[k] = v
}
return out
} catch {
return {}
}
}

function readFloatingBallSize(): FloatingBallSize {
if (typeof document === "undefined") return DEFAULT_FLOATING_BALL_SIZE
try {
const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_SIZE)
return isFloatingBallSize(raw) ? raw : DEFAULT_FLOATING_BALL_SIZE
} catch {
return DEFAULT_FLOATING_BALL_SIZE
}
}

function readFloatingBallFavoriteAgents(): string[] {
if (typeof document === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((v): v is string => typeof v === "string").slice(0, 5)
} catch {
return []
}
}

function readNumber(
key: string,
def: number,
Expand Down Expand Up @@ -418,6 +530,19 @@ export function AppearanceProvider({
}
}, [chatAnimations])

// 悬浮球:默认不显示,显式开启后才挂载;位置缺省为右下角。
const [floatingBallEnabled, setFloatingBallEnabledState] = useState<boolean>(
() => readBool(STORAGE_KEY_FLOATING_BALL_ENABLED, false)
)
const [floatingBallPosition, setFloatingBallPositionState] =
useState<FloatingBallPosition | null>(() => readFloatingBallPosition())
const [floatingBallBindings, setFloatingBallBindingsState] =
useState<FloatingBallBindingsOverride>(() => readFloatingBallBindings())
const [floatingBallSize, setFloatingBallSizeState] =
useState<FloatingBallSize>(() => readFloatingBallSize())
const [floatingBallFavoriteAgents, setFloatingBallFavoriteAgentsState] =
useState<string[]>(() => readFloatingBallFavoriteAgents())

// 聊天内容宽度:DOM 变量由 inline 脚本预置,这里持有状态供设置页回显。
const [chatContentWidth, setChatContentWidthState] = useState<number | null>(
() => readChatContentWidth()
Expand Down Expand Up @@ -582,6 +707,60 @@ export function AppearanceProvider({
persist(STORAGE_KEY_CHAT_ANIMATIONS, on ? "1" : "0")
}, [])

const setFloatingBallEnabled = useCallback((on: boolean) => {
setFloatingBallEnabledState(on)
persist(STORAGE_KEY_FLOATING_BALL_ENABLED, on ? "1" : "0")
}, [])

const setFloatingBallPosition = useCallback(
(position: FloatingBallPosition | null) => {
setFloatingBallPositionState(position)
if (position === null) {
try {
localStorage.removeItem(STORAGE_KEY_FLOATING_BALL_POSITION)
} catch {
// 存不进去也不影响本窗口——位置只是便利,不是数据。
}
return
}
persist(
STORAGE_KEY_FLOATING_BALL_POSITION,
JSON.stringify({ x: Math.round(position.x), y: Math.round(position.y) })
)
},
[]
)

const setFloatingBallBindings = useCallback(
(bindings: FloatingBallBindingsOverride) => {
setFloatingBallBindingsState(bindings)
const hasKeys = Object.keys(bindings).length > 0
if (!hasKeys) {
try {
localStorage.removeItem(STORAGE_KEY_FLOATING_BALL_BINDINGS)
} catch {
// 存不进去也不影响本窗口——绑定只是便利,不是数据。
}
return
}
persist(STORAGE_KEY_FLOATING_BALL_BINDINGS, JSON.stringify(bindings))
},
[]
)

const setFloatingBallSize = useCallback((size: FloatingBallSize) => {
setFloatingBallSizeState(size)
persist(STORAGE_KEY_FLOATING_BALL_SIZE, size)
}, [])

const setFloatingBallFavoriteAgents = useCallback((agents: string[]) => {
setFloatingBallFavoriteAgentsState(agents)
persist(
STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS,
JSON.stringify(agents.slice(0, 5))
)
}, [])

const setChatContentWidth = useCallback((px: number | null) => {
chatContentWidthRef.current = px
setChatContentWidthState(px)
Expand Down Expand Up @@ -1033,6 +1212,24 @@ export function AppearanceProvider({
if (e.key === STORAGE_KEY_CHAT_ANIMATIONS) {
setChatAnimationsState(readBool(STORAGE_KEY_CHAT_ANIMATIONS, true))
}
// 悬浮球同样在外观设置里开关,那边的改动要立刻反映到工作区窗口。
if (e.key === STORAGE_KEY_FLOATING_BALL_ENABLED) {
setFloatingBallEnabledState(
readBool(STORAGE_KEY_FLOATING_BALL_ENABLED, false)
)
}
if (e.key === STORAGE_KEY_FLOATING_BALL_POSITION) {
setFloatingBallPositionState(readFloatingBallPosition())
}
if (e.key === STORAGE_KEY_FLOATING_BALL_BINDINGS) {
setFloatingBallBindingsState(readFloatingBallBindings())
}
if (e.key === STORAGE_KEY_FLOATING_BALL_SIZE) {
setFloatingBallSizeState(readFloatingBallSize())
}
if (e.key === STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS) {
setFloatingBallFavoriteAgentsState(readFloatingBallFavoriteAgents())
}
if (e.key && FONT_KEYS.has(e.key)) {
rehydrateFonts()
}
Expand Down Expand Up @@ -1139,6 +1336,16 @@ export function AppearanceProvider({
setShowWelcomeQuickActions,
chatAnimations,
setChatAnimations,
floatingBallEnabled,
setFloatingBallEnabled,
floatingBallPosition,
setFloatingBallPosition,
floatingBallBindings,
setFloatingBallBindings,
floatingBallSize,
setFloatingBallSize,
floatingBallFavoriteAgents,
setFloatingBallFavoriteAgents,
chatContentWidth,
setChatContentWidth,
previewChatContentWidth: applyChatContentWidth,
Expand Down
4 changes: 4 additions & 0 deletions src/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface ChatInputProps {
agentName?: string
onFocus?: () => void
onSend: (draft: PromptDraft, modeId?: string | null) => void
/** Pass-through: see `MessageInput.modeSelectorSignal`. */
modeSelectorSignal?: number
onCancel: () => void
modes?: SessionModeInfo[]
configOptions?: SessionConfigOptionInfo[]
Expand Down Expand Up @@ -109,6 +111,7 @@ export const ChatInput = memo(function ChatInput({
agentName,
onFocus,
onSend,
modeSelectorSignal,
onCancel,
modes,
configOptions,
Expand Down Expand Up @@ -205,6 +208,7 @@ export const ChatInput = memo(function ChatInput({
)}
<MessageInput
onSend={onSend}
modeSelectorSignal={modeSelectorSignal}
promptCapabilities={promptCapabilities}
onFocus={onFocus}
defaultPath={defaultPath}
Expand Down
4 changes: 4 additions & 0 deletions src/components/chat/conversation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ interface ConversationShellProps {
selectorsLoading?: boolean
selectedModeId?: string | null
onModeChange?: (modeId: string) => void
/** Pass-through: see `MessageInput.modeSelectorSignal`. */
modeSelectorSignal?: number
onConfigOptionChange?: (configId: string, valueId: string) => void
agentType?: AgentType | null
availableCommands?: AvailableCommandInfo[] | null
Expand Down Expand Up @@ -198,6 +200,7 @@ export function ConversationShell({
selectorsLoading = false,
selectedModeId,
onModeChange,
modeSelectorSignal,
onConfigOptionChange,
agentType,
availableCommands,
Expand Down Expand Up @@ -310,6 +313,7 @@ export function ConversationShell({
{!hideInput && (
<div className="mx-auto w-full chat-content-w">
<ChatInput
modeSelectorSignal={modeSelectorSignal}
status={status}
promptCapabilities={promptCapabilities}
defaultPath={defaultPath}
Expand Down
15 changes: 15 additions & 0 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ export interface ComposerInjectContent {

interface MessageInputProps {
onSend: (draft: PromptDraft, modeId?: string | null) => void
/** Bumped by the host (floating ball "mode selector" action) to open the
* inline mode dropdown; the effect below re-fires on every increment. */
modeSelectorSignal?: number
placeholder?: string
defaultPath?: string
disabled?: boolean
Expand Down Expand Up @@ -311,6 +314,7 @@ function SelectorLoadingChip({ label }: { label: string }) {

export function MessageInput({
onSend,
modeSelectorSignal: modeSelectorSignalProp = 0,
placeholder,
defaultPath,
disabled = false,
Expand Down Expand Up @@ -907,6 +911,17 @@ export function MessageInput({
// Antigravity after its commands on `/`.
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
// One-shot signal from the floating ball's "mode selector" action: the
// imperative openModeSelector handle bumps a counter; the effect below
// re-opens the dropdown when it advances. Programmatic trigger clicks are
// idempotent while the menu is already open.
useEffect(() => {
if (modeSelectorSignalProp === 0) return
const trigger = containerRef.current?.querySelector<HTMLButtonElement>(
"[data-mode-selector-trigger]"
)
trigger?.click()
}, [modeSelectorSignalProp])
// The trigger char (`/` for agent commands, `$` for Codex skills) and the
// typed filter token, both derived from the editor caret by
// `detectSlashTrigger` rather than from a raw string offset.
Expand Down
Loading