Refactor logs tab to Info tab with instance information and logs
- Create reusable InstanceInfo component extracted from instance-welcome-view - Create InfoView component combining instance info and logs sections - Replace LogsView with InfoView in App.tsx - Rename 'Logs' tab to 'Info' with Info icon - Update all references from 'logs' to 'info' throughout codebase - Maintain scroll position and auto-scroll behavior for logs - Add dark mode support to all components
This commit is contained in:
34
src/App.tsx
34
src/App.tsx
@@ -8,7 +8,7 @@ import InstanceTabs from "./components/instance-tabs"
|
|||||||
import SessionTabs from "./components/session-tabs"
|
import SessionTabs from "./components/session-tabs"
|
||||||
import MessageStream from "./components/message-stream"
|
import MessageStream from "./components/message-stream"
|
||||||
import PromptInput from "./components/prompt-input"
|
import PromptInput from "./components/prompt-input"
|
||||||
import LogsView from "./components/logs-view"
|
import InfoView from "./components/info-view"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
import { createCommandRegistry } from "./lib/commands"
|
import { createCommandRegistry } from "./lib/commands"
|
||||||
import type { Command } from "./lib/commands"
|
import type { Command } from "./lib/commands"
|
||||||
@@ -328,21 +328,21 @@ const App: Component = () => {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!instance || !sessionId || sessionId === "logs") return
|
if (!instance || !sessionId || sessionId === "info") return
|
||||||
await handleCloseSession(instance.id, sessionId)
|
await handleCloseSession(instance.id, sessionId)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "switch-to-logs",
|
id: "switch-to-info",
|
||||||
label: "Switch to Logs",
|
label: "Switch to Info",
|
||||||
description: "Jump to logs view for current instance",
|
description: "Jump to info view for current instance",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keywords: ["logs", "console", "output"],
|
keywords: ["info", "info", "console", "output"],
|
||||||
shortcut: { key: "L", meta: true, shift: true },
|
shortcut: { key: "L", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
if (instance) setActiveSession(instance.id, "logs")
|
if (instance) setActiveSession(instance.id, "info")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ const App: Component = () => {
|
|||||||
const parentId = activeParentSessionId().get(instanceId)
|
const parentId = activeParentSessionId().get(instanceId)
|
||||||
if (!parentId) return
|
if (!parentId) return
|
||||||
const familySessions = getSessionFamily(instanceId, parentId)
|
const familySessions = getSessionFamily(instanceId, parentId)
|
||||||
const ids = familySessions.map((s) => s.id).concat(["logs"])
|
const ids = familySessions.map((s) => s.id).concat(["info"])
|
||||||
if (ids.length <= 1) return
|
if (ids.length <= 1) return
|
||||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
||||||
const next = (current + 1) % ids.length
|
const next = (current + 1) % ids.length
|
||||||
@@ -380,7 +380,7 @@ const App: Component = () => {
|
|||||||
const parentId = activeParentSessionId().get(instanceId)
|
const parentId = activeParentSessionId().get(instanceId)
|
||||||
if (!parentId) return
|
if (!parentId) return
|
||||||
const familySessions = getSessionFamily(instanceId, parentId)
|
const familySessions = getSessionFamily(instanceId, parentId)
|
||||||
const ids = familySessions.map((s) => s.id).concat(["logs"])
|
const ids = familySessions.map((s) => s.id).concat(["info"])
|
||||||
if (ids.length <= 1) return
|
if (ids.length <= 1) return
|
||||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
||||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||||
@@ -397,7 +397,7 @@ const App: Component = () => {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!instance || !instance.client || !sessionId || sessionId === "logs") return
|
if (!instance || !instance.client || !sessionId || sessionId === "info") return
|
||||||
|
|
||||||
const sessions = getSessions(instance.id)
|
const sessions = getSessions(instance.id)
|
||||||
const session = sessions.find((s) => s.id === sessionId)
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
@@ -429,7 +429,7 @@ const App: Component = () => {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!instance || !instance.client || !sessionId || sessionId === "logs") return
|
if (!instance || !instance.client || !sessionId || sessionId === "info") return
|
||||||
|
|
||||||
const sessions = getSessions(instance.id)
|
const sessions = getSessions(instance.id)
|
||||||
const session = sessions.find((s) => s.id === sessionId)
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
@@ -567,7 +567,7 @@ const App: Component = () => {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!instance || !instance.client || !sessionId || sessionId === "logs") return
|
if (!instance || !instance.client || !sessionId || sessionId === "info") return
|
||||||
|
|
||||||
const sessions = getSessions(instance.id)
|
const sessions = getSessions(instance.id)
|
||||||
const session = sessions.find((s) => s.id === sessionId)
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
@@ -703,7 +703,7 @@ const App: Component = () => {
|
|||||||
if (!instance) return false
|
if (!instance) return false
|
||||||
|
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!sessionId || sessionId === "logs") return false
|
if (!sessionId || sessionId === "info") return false
|
||||||
|
|
||||||
const sessions = getSessions(instance.id)
|
const sessions = getSessions(instance.id)
|
||||||
const session = sessions.find((s) => s.id === sessionId)
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
@@ -727,7 +727,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!instance || !sessionId || sessionId === "logs") return
|
if (!instance || !sessionId || sessionId === "info") return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await abortSession(instance.id, sessionId)
|
await abortSession(instance.id, sessionId)
|
||||||
@@ -820,13 +820,13 @@ const App: Component = () => {
|
|||||||
|
|
||||||
<div class="content-area flex-1 overflow-hidden flex flex-col">
|
<div class="content-area flex-1 overflow-hidden flex flex-col">
|
||||||
<Show
|
<Show
|
||||||
when={activeSessionIdForInstance() === "logs"}
|
when={activeSessionIdForInstance() === "info"}
|
||||||
fallback={
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={activeSessionIdForInstance()}
|
when={activeSessionIdForInstance()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="flex items-center justify-center h-full">
|
||||||
<div class="text-center text-gray-500">
|
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||||
<p class="mb-2">No session selected</p>
|
<p class="mb-2">No session selected</p>
|
||||||
<p class="text-sm">Select a session to view messages</p>
|
<p class="text-sm">Select a session to view messages</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -843,7 +843,7 @@ const App: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LogsView instanceId={instance().id} />
|
<InfoView instanceId={instance().id} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
131
src/components/info-view.tsx
Normal file
131
src/components/info-view.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup } from "solid-js"
|
||||||
|
import { instances } from "../stores/instances"
|
||||||
|
import { ChevronDown } from "lucide-solid"
|
||||||
|
import type { LogEntry } from "../types/instance"
|
||||||
|
import InstanceInfo from "./instance-info"
|
||||||
|
|
||||||
|
interface InfoViewProps {
|
||||||
|
instanceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||||
|
|
||||||
|
const InfoView: Component<InfoViewProps> = (props) => {
|
||||||
|
let scrollRef: HTMLDivElement | undefined
|
||||||
|
const savedState = logsScrollState.get(props.instanceId)
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||||
|
|
||||||
|
const instance = () => instances().get(props.instanceId)
|
||||||
|
const logs = () => instance()?.logs ?? []
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (scrollRef && savedState) {
|
||||||
|
scrollRef.scrollTop = savedState.scrollTop
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (scrollRef) {
|
||||||
|
logsScrollState.set(props.instanceId, {
|
||||||
|
scrollTop: scrollRef.scrollTop,
|
||||||
|
autoScroll: autoScroll(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (autoScroll() && scrollRef && logs().length > 0) {
|
||||||
|
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!scrollRef) return
|
||||||
|
|
||||||
|
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
|
||||||
|
|
||||||
|
setAutoScroll(isAtBottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (scrollRef) {
|
||||||
|
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||||
|
setAutoScroll(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour12: false,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case "error":
|
||||||
|
return "text-red-600 dark:text-red-400"
|
||||||
|
case "warn":
|
||||||
|
return "text-yellow-600 dark:text-yellow-400"
|
||||||
|
case "debug":
|
||||||
|
return "text-gray-500 dark:text-gray-500"
|
||||||
|
default:
|
||||||
|
return "text-gray-900 dark:text-gray-100"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||||
|
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||||
|
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col min-h-0 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Server Logs</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-gray-900 font-mono text-xs leading-relaxed"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={logs().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="text-gray-500 dark:text-gray-500 text-center py-8">Waiting for server output...</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={logs()}>
|
||||||
|
{(entry) => (
|
||||||
|
<div class="flex gap-3 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 px-2 -mx-2 rounded">
|
||||||
|
<span class="text-gray-500 dark:text-gray-500 select-none shrink-0">
|
||||||
|
{formatTime(entry.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span class={`${getLevelColor(entry.level)} break-all`}>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!autoScroll()}>
|
||||||
|
<button
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
class="absolute bottom-6 right-6 px-3 py-2 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center gap-1 text-sm"
|
||||||
|
>
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
Scroll to bottom
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoView
|
||||||
236
src/components/instance-info.tsx
Normal file
236
src/components/instance-info.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { Component, Show, For, onMount, createSignal } from "solid-js"
|
||||||
|
import type { Instance } from "../types/instance"
|
||||||
|
|
||||||
|
interface InstanceInfoProps {
|
||||||
|
instance: Instance
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMcpStatus(status: unknown): Array<{ name: string; status: "running" | "stopped" | "error" }> {
|
||||||
|
if (!status || typeof status !== "object") return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const obj = status as Record<string, string>
|
||||||
|
return Object.entries(obj).map(([name, statusValue]) => {
|
||||||
|
let mappedStatus: "running" | "stopped" | "error"
|
||||||
|
|
||||||
|
if (statusValue === "connected") {
|
||||||
|
mappedStatus = "running"
|
||||||
|
} else if (statusValue === "disabled") {
|
||||||
|
mappedStatus = "stopped"
|
||||||
|
} else if (statusValue === "failed") {
|
||||||
|
mappedStatus = "error"
|
||||||
|
} else {
|
||||||
|
mappedStatus = "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
status: mappedStatus,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||||
|
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
||||||
|
|
||||||
|
const metadata = () => props.instance.metadata
|
||||||
|
const mcpServers = () => {
|
||||||
|
const status = metadata()?.mcpStatus
|
||||||
|
return parseMcpStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!props.instance.client) {
|
||||||
|
setIsLoadingMetadata(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingMetadata(true)
|
||||||
|
try {
|
||||||
|
const [projectResult, mcpResult] = await Promise.allSettled([
|
||||||
|
props.instance.client.project.current(),
|
||||||
|
props.instance.client.mcp.status(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
||||||
|
const mcpStatus = mcpResult.status === "fulfilled" ? mcpResult.value.data : undefined
|
||||||
|
|
||||||
|
const { updateInstance } = await import("../stores/instances")
|
||||||
|
updateInstance(props.instance.id, {
|
||||||
|
metadata: {
|
||||||
|
project,
|
||||||
|
mcpStatus,
|
||||||
|
version: "0.15.8",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load instance metadata:", error)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMetadata(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Instance Information</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Folder</div>
|
||||||
|
<div class="text-xs text-gray-900 dark:text-gray-100 font-mono break-all bg-gray-50 dark:bg-gray-900 px-2 py-1.5 rounded border border-gray-200 dark:border-gray-700">
|
||||||
|
{props.instance.folder}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!isLoadingMetadata() && metadata()?.project}>
|
||||||
|
{(project) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
Project
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-900 dark:text-gray-100 font-mono bg-gray-50 dark:bg-gray-900 px-2 py-1.5 rounded border border-gray-200 dark:border-gray-700 truncate">
|
||||||
|
{project().id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={project().vcs}>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
Version Control
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-900 dark:text-gray-100">
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5 text-orange-600 dark:text-orange-500"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
<span class="capitalize">{project().vcs}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={metadata()?.version}>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
OpenCode Version
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-900 px-2 py-1.5 rounded border border-gray-200 dark:border-gray-700">
|
||||||
|
v{metadata()?.version}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.instance.binaryPath}>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
Binary Path
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-900 dark:text-gray-100 font-mono break-all bg-gray-50 dark:bg-gray-900 px-2 py-1.5 rounded border border-gray-200 dark:border-gray-700">
|
||||||
|
{props.instance.binaryPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
|
||||||
|
MCP Servers
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<For each={mcpServers()}>
|
||||||
|
{(server) => (
|
||||||
|
<div class="flex items-center justify-between px-2 py-1.5 bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="text-xs text-gray-900 dark:text-gray-100 font-medium truncate">{server.name}</span>
|
||||||
|
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<Show
|
||||||
|
when={server.status === "running"}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={server.status === "error"}
|
||||||
|
fallback={<div class="w-1.5 h-1.5 rounded-full bg-gray-400" />}
|
||||||
|
>
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={isLoadingMetadata()}>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 py-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">Server</div>
|
||||||
|
<div class="space-y-1 text-xs">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Port:</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-100 font-mono">{props.instance.port}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">PID:</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-100 font-mono">{props.instance.pid}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Status:</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium"
|
||||||
|
classList={{
|
||||||
|
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400":
|
||||||
|
props.instance.status === "ready",
|
||||||
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400":
|
||||||
|
props.instance.status === "starting",
|
||||||
|
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400": props.instance.status === "error",
|
||||||
|
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300": props.instance.status === "stopped",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-1 h-1 rounded-full"
|
||||||
|
classList={{
|
||||||
|
"bg-green-600 animate-pulse": props.instance.status === "ready",
|
||||||
|
"bg-yellow-600 animate-pulse": props.instance.status === "starting",
|
||||||
|
"bg-red-600": props.instance.status === "error",
|
||||||
|
"bg-gray-600": props.instance.status === "stopped",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{props.instance.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InstanceInfo
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup } from "solid-js"
|
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup } from "solid-js"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { getParentSessions, createSession, setActiveParentSession, agents } from "../stores/sessions"
|
import { getParentSessions, createSession, setActiveParentSession, agents } from "../stores/sessions"
|
||||||
|
import InstanceInfo from "./instance-info"
|
||||||
|
|
||||||
interface InstanceWelcomeViewProps {
|
interface InstanceWelcomeViewProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
@@ -9,13 +10,11 @@ interface InstanceWelcomeViewProps {
|
|||||||
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||||
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
||||||
const [isCreating, setIsCreating] = createSignal(false)
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||||
|
|
||||||
const parentSessions = () => getParentSessions(props.instance.id)
|
const parentSessions = () => getParentSessions(props.instance.id)
|
||||||
const agentList = () => agents().get(props.instance.id) || []
|
const agentList = () => agents().get(props.instance.id) || []
|
||||||
const metadata = () => props.instance.metadata
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const list = agentList()
|
const list = agentList()
|
||||||
@@ -35,38 +34,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await loadInstanceMetadata()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadInstanceMetadata() {
|
|
||||||
if (!props.instance.client) return
|
|
||||||
|
|
||||||
setIsLoadingMetadata(true)
|
|
||||||
try {
|
|
||||||
const [projectResult, mcpResult] = await Promise.allSettled([
|
|
||||||
props.instance.client.project.current(),
|
|
||||||
props.instance.client.mcp.status(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
|
||||||
const mcpStatus = mcpResult.status === "fulfilled" ? mcpResult.value.data : undefined
|
|
||||||
|
|
||||||
const { updateInstance } = await import("../stores/instances")
|
|
||||||
updateInstance(props.instance.id, {
|
|
||||||
metadata: {
|
|
||||||
project,
|
|
||||||
mcpStatus,
|
|
||||||
version: "0.15.8",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load instance metadata:", error)
|
|
||||||
} finally {
|
|
||||||
setIsLoadingMetadata(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToIndex(index: number) {
|
function scrollToIndex(index: number) {
|
||||||
const element = document.querySelector(`[data-session-index="${index}"]`)
|
const element = document.querySelector(`[data-session-index="${index}"]`)
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -178,39 +145,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMcpStatus(status: unknown): Array<{ name: string; status: "running" | "stopped" | "error" }> {
|
|
||||||
if (!status || typeof status !== "object") return []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const obj = status as Record<string, string>
|
|
||||||
return Object.entries(obj).map(([name, statusValue]) => {
|
|
||||||
let mappedStatus: "running" | "stopped" | "error"
|
|
||||||
|
|
||||||
if (statusValue === "connected") {
|
|
||||||
mappedStatus = "running"
|
|
||||||
} else if (statusValue === "disabled") {
|
|
||||||
mappedStatus = "stopped"
|
|
||||||
} else if (statusValue === "failed") {
|
|
||||||
mappedStatus = "error"
|
|
||||||
} else {
|
|
||||||
mappedStatus = "stopped"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
status: mappedStatus,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mcpServers = () => {
|
|
||||||
const status = metadata()?.mcpStatus
|
|
||||||
return parseMcpStatus(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
<div class="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
||||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto">
|
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto">
|
||||||
@@ -354,146 +288,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:w-80 flex-shrink-0">
|
<div class="lg:w-80 flex-shrink-0">
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 sticky top-0">
|
<div class="sticky top-0">
|
||||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
<InstanceInfo instance={props.instance} />
|
||||||
<h2 class="text-base font-semibold text-gray-900">Instance Information</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Folder</div>
|
|
||||||
<div class="text-xs text-gray-900 font-mono break-all bg-gray-50 px-2 py-1.5 rounded border border-gray-200">
|
|
||||||
{props.instance.folder}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={!isLoadingMetadata() && metadata()?.project}>
|
|
||||||
{(project) => (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Project</div>
|
|
||||||
<div class="text-xs text-gray-900 font-mono bg-gray-50 px-2 py-1.5 rounded border border-gray-200 truncate">
|
|
||||||
{project().id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={project().vcs}>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
|
|
||||||
Version Control
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-900">
|
|
||||||
<svg class="w-3.5 h-3.5 text-orange-600" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
<span class="capitalize">{project().vcs}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={metadata()?.version}>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">OpenCode Version</div>
|
|
||||||
<div class="text-xs text-gray-900 bg-gray-50 px-2 py-1.5 rounded border border-gray-200">
|
|
||||||
v{metadata()?.version}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.instance.binaryPath}>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Binary Path</div>
|
|
||||||
<div class="text-xs text-gray-900 font-mono break-all bg-gray-50 px-2 py-1.5 rounded border border-gray-200">
|
|
||||||
{props.instance.binaryPath}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">MCP Servers</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<For each={mcpServers()}>
|
|
||||||
{(server) => (
|
|
||||||
<div class="flex items-center justify-between px-2 py-1.5 bg-gray-50 rounded border border-gray-200">
|
|
||||||
<span class="text-xs text-gray-900 font-medium truncate">{server.name}</span>
|
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
<Show
|
|
||||||
when={server.status === "running"}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={server.status === "error"}
|
|
||||||
fallback={<div class="w-1.5 h-1.5 rounded-full bg-gray-400" />}
|
|
||||||
>
|
|
||||||
<div class="w-1.5 h-1.5 rounded-full bg-red-500" />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={isLoadingMetadata()}>
|
|
||||||
<div class="text-xs text-gray-500 py-1">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">Server</div>
|
|
||||||
<div class="space-y-1 text-xs">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Port:</span>
|
|
||||||
<span class="text-gray-900 font-mono">{props.instance.port}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">PID:</span>
|
|
||||||
<span class="text-gray-900 font-mono">{props.instance.pid}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Status:</span>
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium"
|
|
||||||
classList={{
|
|
||||||
"bg-green-100 text-green-800": props.instance.status === "ready",
|
|
||||||
"bg-yellow-100 text-yellow-800": props.instance.status === "starting",
|
|
||||||
"bg-red-100 text-red-800": props.instance.status === "error",
|
|
||||||
"bg-gray-100 text-gray-800": props.instance.status === "stopped",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-1 h-1 rounded-full"
|
|
||||||
classList={{
|
|
||||||
"bg-green-600 animate-pulse": props.instance.status === "ready",
|
|
||||||
"bg-yellow-600 animate-pulse": props.instance.status === "starting",
|
|
||||||
"bg-red-600": props.instance.status === "error",
|
|
||||||
"bg-gray-600": props.instance.status === "stopped",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{props.instance.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Component, Show } from "solid-js"
|
import { Component, Show } from "solid-js"
|
||||||
import type { Session } from "../types/session"
|
import type { Session } from "../types/session"
|
||||||
import { MessageSquare, Terminal, X } from "lucide-solid"
|
import { MessageSquare, Info, X } from "lucide-solid"
|
||||||
|
|
||||||
interface SessionTabProps {
|
interface SessionTabProps {
|
||||||
session?: Session
|
session?: Session
|
||||||
special?: "logs"
|
special?: "info"
|
||||||
active: boolean
|
active: boolean
|
||||||
isParent?: boolean
|
isParent?: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
@@ -13,7 +13,7 @@ interface SessionTabProps {
|
|||||||
|
|
||||||
const SessionTab: Component<SessionTabProps> = (props) => {
|
const SessionTab: Component<SessionTabProps> = (props) => {
|
||||||
const label = () => {
|
const label = () => {
|
||||||
if (props.special === "logs") return "Logs"
|
if (props.special === "info") return "Info"
|
||||||
return props.session?.title || "Untitled"
|
return props.session?.title || "Untitled"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,16 +22,16 @@ const SessionTab: Component<SessionTabProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
class={`session-tab inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm ${
|
class={`session-tab inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm ${
|
||||||
props.active
|
props.active
|
||||||
? "bg-white border-b-2 border-blue-500 font-medium text-gray-900"
|
? "bg-white dark:bg-gray-800 border-b-2 border-blue-500 font-medium text-gray-900 dark:text-gray-100"
|
||||||
: "text-gray-600 hover:bg-gray-100"
|
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
} ${props.special === "logs" ? "text-gray-500" : ""} ${props.isParent && !props.active ? "font-semibold" : ""}`}
|
} ${props.special === "info" ? "text-gray-500 dark:text-gray-400" : ""} ${props.isParent && !props.active ? "font-semibold" : ""}`}
|
||||||
onClick={props.onSelect}
|
onClick={props.onSelect}
|
||||||
title={label()}
|
title={label()}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={props.active}
|
aria-selected={props.active}
|
||||||
>
|
>
|
||||||
<Show when={props.special === "logs"} fallback={<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />}>
|
<Show when={props.special === "info"} fallback={<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />}>
|
||||||
<Terminal class="w-3.5 h-3.5 flex-shrink-0" />
|
<Info class="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
<span class="tab-label truncate">{label()}</span>
|
<span class="tab-label truncate">{label()}</span>
|
||||||
<Show when={!props.special && props.onClose}>
|
<Show when={!props.special && props.onClose}>
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ const SessionTabs: Component<SessionTabsProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<SessionTab
|
<SessionTab
|
||||||
special="logs"
|
special="info"
|
||||||
active={props.activeSessionId === "logs"}
|
active={props.activeSessionId === "info"}
|
||||||
onSelect={() => props.onSelect("logs")}
|
onSelect={() => props.onSelect("info")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
|
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user