Skip to content
Open
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
113 changes: 102 additions & 11 deletions packages/ui/src/components/info-view.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming, clearLogs } from "../stores/instances"
import { ArrowLeft, Trash2 } from "lucide-solid"
import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n"

interface InfoViewProps {
instanceId: string
onBackToConversation?: () => void
}

const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
Expand All @@ -15,19 +16,27 @@ const InfoView: Component<InfoViewProps> = (props) => {
let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)

const instance = () => instances().get(props.instanceId)
const logs = createMemo(() => getInstanceLogs(props.instanceId))
const streamingEnabled = createMemo(() => isInstanceLogStreaming(props.instanceId))

const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true)
const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false)
const handleClearLogs = () => {
clearLogs(props.instanceId)
updateScrollButtons()
}

onMount(() => {

if (scrollRef && savedState) {
scrollRef.scrollTop = savedState.scrollTop
}
// 初始化滾動按鈕可見性 / Initialize scroll button visibility
updateScrollButtons()
})

onCleanup(() => {
Expand All @@ -45,18 +54,51 @@ const InfoView: Component<InfoViewProps> = (props) => {
}
})

// 監聽日誌變化並更新滾動按鈕 / Listen for log changes and update scroll buttons
createEffect(() => {
logs() // 追蹤 logs 變化
updateScrollButtons()
})

/** 更新滾動按鈕顯示狀態 / Update scroll button visibility */
const updateScrollButtons = () => {
if (!scrollRef) return

const scrollTop = scrollRef.scrollTop
const scrollHeight = scrollRef.scrollHeight
const clientHeight = scrollRef.clientHeight
const hasItems = logs().length > 0

const atBottom = scrollHeight - (scrollTop + clientHeight) <= 50
const atTop = scrollTop <= 50

setShowScrollBottomButton(hasItems && !atBottom)
setShowScrollTopButton(hasItems && !atTop)
}

/** 滾動至頂部 / Scroll to top */
const scrollToTop = () => {
if (scrollRef) {
scrollRef.scrollTop = 0
setAutoScroll(false)
updateScrollButtons()
}
}

const handleScroll = () => {
if (!scrollRef) return

const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50

setAutoScroll(isAtBottom)
updateScrollButtons()
}

const scrollToBottom = () => {
if (scrollRef) {
scrollRef.scrollTop = scrollRef.scrollHeight
setAutoScroll(true)
updateScrollButtons()
}
}

Expand All @@ -83,6 +125,11 @@ const InfoView: Component<InfoViewProps> = (props) => {
}
}

/** 是否顯示浮動滾動按鈕 / Whether to show floating scroll buttons */
const showScrollButtons = createMemo(() => {
return streamingEnabled() && (showScrollTopButton() || showScrollBottomButton())
})

return (
<div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
Expand All @@ -94,6 +141,28 @@ const InfoView: Component<InfoViewProps> = (props) => {
<div class="log-header">
<h2 class="panel-title">{t("infoView.logs.title")}</h2>
<div class="flex items-center gap-2">
<Show when={props.onBackToConversation}>
{(onBack) => (
<button
type="button"
class="button-tertiary"
onClick={onBack}
title={t("infoView.logs.actions.back")}
>
<ArrowLeft class="w-4 h-4" />
</button>
)}
</Show>
<Show when={logs().length > 0}>
<button
type="button"
class="button-tertiary"
onClick={handleClearLogs}
title={t("infoView.logs.actions.clear")}
>
<Trash2 class="w-4 h-4" />
</button>
</Show>
<Show
when={streamingEnabled()}
fallback={
Expand Down Expand Up @@ -143,15 +212,37 @@ const InfoView: Component<InfoViewProps> = (props) => {
</Show>
</Show>
</div>

<Show when={!autoScroll() && streamingEnabled()}>
<button
onClick={scrollToBottom}
class="scroll-to-bottom"
>
<ChevronDown class="w-4 h-4" />
{t("infoView.logs.scrollToBottom")}
</button>

{/* 浮動滾動按鈕 / Floating scroll buttons */}
<Show when={showScrollButtons()}>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={scrollToTop}
aria-label={t("infoView.logs.scrollToTop")}
title={t("infoView.logs.scrollToTop")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={scrollToBottom}
aria-label={t("infoView.logs.scrollToBottom")}
title={t("infoView.logs.scrollToBottom")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
</Show>
</div>
</div>
Expand Down
165 changes: 99 additions & 66 deletions packages/ui/src/components/instance-tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Dynamic } from "solid-js/web"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import ToastHistoryPanel from "./toast-history-panel"
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { getUnreadToastCountSignal } from "../lib/notifications"
import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs"
Expand All @@ -22,13 +24,19 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()

/** 是否顯示 Toast 歷史面板 / Whether to show toast history panel */
const [showToastHistory, setShowToastHistory] = createSignal(false)

const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
const notificationIcon = createMemo(() => {
if (!notificationsSupported()) return BellOff
return notificationsEnabled() ? Bell : BellOff
})

/** 未讀通知數量(響應式信號)/ Unread notification count (reactive signal) */
const unreadCount = getUnreadToastCountSignal()

const notificationTitle = createMemo(() => {
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
return notificationsEnabled()
Expand All @@ -37,81 +45,106 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
})

return (
<div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist">
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<For each={props.tabs}>
{(tab) =>
tab.kind === "instance" ? (
<InstanceTab
instance={tab.instance}
active={tab.id === props.activeTabId}
onSelect={() => props.onSelect(tab.id)}
onClose={() => props.onClose(tab.id)}
/>
) : (
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
</button>
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
×
</button>
</div>
)}
</For>
<>
<div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist">
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<For each={props.tabs}>
{(tab) =>
tab.kind === "instance" ? (
<InstanceTab
instance={tab.instance}
active={tab.id === props.activeTabId}
onSelect={() => props.onSelect(tab.id)}
onClose={() => props.onClose(tab.id)}
/>
) : (
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
</button>
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
×
</button>
</div>
)}
</For>
<button
class="new-tab-button"
onClick={props.onNew}
title={t("instanceTabs.new.title")}
aria-label={t("instanceTabs.new.ariaLabel")}
>
<Plus class="w-4 h-4" />
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={props.tabs.length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
</div>
</Show>

<button
class="new-tab-button"
onClick={props.onNew}
title={t("instanceTabs.new.title")}
aria-label={t("instanceTabs.new.ariaLabel")}
onClick={() => openSettings("appearance")}
title={t("settings.open.title")}
aria-label={t("settings.open.ariaLabel")}
>
<Plus class="w-4 h-4" />
<Settings class="w-4 h-4" />
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={props.tabs.length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
</div>
</Show>
<button
class="new-tab-button"
onClick={() => openSettings("appearance")}
title={t("settings.open.title")}
aria-label={t("settings.open.ariaLabel")}
>
<Settings class="w-4 h-4" />
</button>

<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => openSettings("notifications")}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
{/* 通知按鈕 / Notification Button */}
<div class="relative">
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => setShowToastHistory(true)}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
{/* 未讀標記 / Unread badge */}
<Show when={unreadCount() > 0}>
<span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
aria-label={t("toastHistory.unread", { count: unreadCount() })}
>
{unreadCount() > 9 ? "9+" : unreadCount()}
</span>
</Show>
</div>

<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>

{/* Toast 歷史面板 / Toast History Panel */}
<Show when={showToastHistory()}>
<ToastHistoryPanel
onClose={() => setShowToastHistory(false)}
onOpenSettings={() => {
setShowToastHistory(false)
openSettings("notifications")
}}
/>
</Show>
</>
)
}

Expand Down
Loading
Loading