Compare commits
11 Commits
feat/centr
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
147c9e3e4b | ||
|
|
ab38cdccac | ||
|
|
8168d52295 | ||
|
|
1081bfb276 | ||
|
|
38064b229c | ||
|
|
1a7aefcbae | ||
|
|
e50d9f461a | ||
|
|
d76cf8a3f7 | ||
|
|
c7370fe7bc | ||
|
|
3dfbe2a5b2 | ||
|
|
e30c8b0253 |
23
README.md
23
README.md
@@ -76,6 +76,29 @@ xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
|
||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||
|
||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
||||
|
||||
Try running with one of these environment variables:
|
||||
|
||||
```bash
|
||||
# Most reliable workaround (can reduce rendering performance)
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
|
||||
# Alternative for some Wayland setups
|
||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
||||
```
|
||||
|
||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
||||
```
|
||||
|
||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -7389,7 +7389,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -7423,7 +7423,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
@@ -7458,14 +7458,14 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.1"
|
||||
"@opencode-ai/plugin": "1.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"commander": "^12.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,14 +26,19 @@ import MenuIcon from "@suid/icons-material/Menu"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
||||
import type { Session } from "../../types/session"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
} from "../../stores/sessions"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||
@@ -87,7 +92,7 @@ const MAX_SESSION_SIDEBAR_WIDTH = 360
|
||||
const RIGHT_DRAWER_WIDTH = 260
|
||||
const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||
const MAX_RIGHT_DRAWER_WIDTH = 380
|
||||
const SESSION_CACHE_LIMIT = 2
|
||||
const SESSION_CACHE_LIMIT = 5
|
||||
const APP_BAR_HEIGHT = 56
|
||||
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||
@@ -268,6 +273,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
requestAnimationFrame(() => measureDrawerHost())
|
||||
})
|
||||
|
||||
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
|
||||
return sessions().get(props.instance.id) ?? new Map()
|
||||
})
|
||||
|
||||
const sessionThreads = createMemo(() => getSessionThreads(props.instance.id))
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||
@@ -477,7 +488,26 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
const handleSessionSelect = (sessionId: string) => {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
if (sessionId === "info") {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const session = allInstanceSessions().get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (session.parentId === null) {
|
||||
setActiveParentSession(props.instance.id, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) return
|
||||
|
||||
batch(() => {
|
||||
setActiveParentSession(props.instance.id, parentId)
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -522,23 +552,27 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionsMap = activeSessions()
|
||||
const parentId = parentSessionIdForInstance()
|
||||
const instanceSessions = allInstanceSessions()
|
||||
const activeId = activeSessionIdForInstance()
|
||||
|
||||
setCachedSessionIds((current) => {
|
||||
const next: string[] = []
|
||||
const append = (id: string | null) => {
|
||||
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
|
||||
|
||||
const touch = (id: string | null) => {
|
||||
if (!id || id === "info") return
|
||||
if (!sessionsMap.has(id)) return
|
||||
if (next.includes(id)) return
|
||||
next.push(id)
|
||||
if (!instanceSessions.has(id)) return
|
||||
|
||||
const index = next.indexOf(id)
|
||||
if (index !== -1) {
|
||||
next.splice(index, 1)
|
||||
}
|
||||
next.unshift(id)
|
||||
}
|
||||
|
||||
append(parentId)
|
||||
append(activeId)
|
||||
touch(activeId)
|
||||
|
||||
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
|
||||
|
||||
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
|
||||
const trimmed = next.length > limit ? next.slice(0, limit) : next
|
||||
const trimmedSet = new Set(trimmed)
|
||||
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||
if (removed.length) {
|
||||
@@ -815,6 +849,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label="Instance Info"
|
||||
title="Instance Info"
|
||||
onClick={() => handleSessionSelect("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -832,15 +875,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
sessions={allInstanceSessions()}
|
||||
threads={sessionThreads()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
onClose={(id) => {
|
||||
const result = props.onCloseSession(id)
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to close session:", error))
|
||||
}
|
||||
}}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
|
||||
@@ -120,6 +120,13 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
onCleanup(() => document.removeEventListener("keydown", closeOnEscape))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.isOpen) return
|
||||
if (queue().length === 0) {
|
||||
props.onClose()
|
||||
}
|
||||
})
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
props.onClose()
|
||||
|
||||
@@ -1030,8 +1030,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
|
||||
const commandHint = () => ({ key: "/", text: "for commands" })
|
||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
|
||||
const commandHint = () => ({ key: "/", text: "Commands" })
|
||||
|
||||
const shouldShowOverlay = () => prompt().length === 0
|
||||
|
||||
@@ -1216,7 +1216,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
fallback={
|
||||
<>
|
||||
<span class="prompt-overlay-text">
|
||||
<Kbd>Enter</Kbd> for new line • <Kbd shortcut="cmd+enter" /> to send • <Kbd>@</Kbd> for files/agents • <Kbd>↑↓</Kbd> for history
|
||||
<Kbd>Enter</Kbd> New line • <Kbd shortcut="cmd+enter" /> Send • <Kbd>@</Kbd> Files/agents • <Kbd>↑↓</Kbd> History
|
||||
</span>
|
||||
<Show when={attachments().length > 0}>
|
||||
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
|
||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { MessageSquare, Info, X, Copy, Trash2, Pencil, ShieldAlert } from "lucide-solid"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { formatShortcut } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { deleteSession, loading, renameSession } from "../stores/sessions"
|
||||
import {
|
||||
deleteSession,
|
||||
ensureSessionParentExpanded,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
loading,
|
||||
renameSession,
|
||||
setActiveSessionFromList,
|
||||
toggleSessionParentExpanded,
|
||||
} from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
const log = getLogger("session")
|
||||
@@ -18,9 +26,9 @@ const log = getLogger("session")
|
||||
interface SessionListProps {
|
||||
instanceId: string
|
||||
sessions: Map<string, Session>
|
||||
threads: SessionThread[]
|
||||
activeSessionId: string | null
|
||||
onSelect: (sessionId: string) => void
|
||||
onClose: (sessionId: string) => void
|
||||
onNew: () => void
|
||||
showHeader?: boolean
|
||||
showFooter?: boolean
|
||||
@@ -39,35 +47,23 @@ function formatSessionStatus(status: SessionStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean {
|
||||
if (!prev) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (prev.length !== next.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
if (prev[i] !== next[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const SessionList: Component<SessionListProps> = (props) => {
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
||||
|
||||
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instanceId)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
const session = props.sessions.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
}
|
||||
|
||||
props.onSelect(sessionId)
|
||||
}
|
||||
|
||||
@@ -90,9 +86,45 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const handleDeleteSession = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
|
||||
const shouldSelectFallback = props.activeSessionId === sessionId
|
||||
let fallbackSessionId: string | undefined
|
||||
|
||||
if (shouldSelectFallback) {
|
||||
const visible = getVisibleSessionIds(props.instanceId)
|
||||
const currentIndex = visible.indexOf(sessionId)
|
||||
const remaining = visible.filter((id) => id !== sessionId)
|
||||
|
||||
if (remaining.length > 0) {
|
||||
if (currentIndex !== -1) {
|
||||
for (let i = currentIndex; i < visible.length; i++) {
|
||||
const candidate = visible[i]
|
||||
if (candidate && candidate !== sessionId) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackSessionId) {
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const candidate = visible[i]
|
||||
if (candidate && candidate !== sessionId) {
|
||||
fallbackSessionId = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fallbackSessionId ??= remaining[0]
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSession(props.instanceId, sessionId)
|
||||
if (fallbackSessionId) {
|
||||
setActiveSessionFromList(props.instanceId, fallbackSessionId)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to delete session", variant: "error" })
|
||||
@@ -127,7 +159,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}
|
||||
|
||||
|
||||
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
|
||||
const SessionRow: Component<{
|
||||
sessionId: string
|
||||
isChild?: boolean
|
||||
isLastChild?: boolean
|
||||
hasChildren?: boolean
|
||||
expanded?: boolean
|
||||
onToggleExpand?: () => void
|
||||
}> = (rowProps) => {
|
||||
const session = () => props.sessions.get(rowProps.sessionId)
|
||||
if (!session()) {
|
||||
return <></>
|
||||
@@ -144,41 +183,55 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div class="session-list-item group">
|
||||
|
||||
<button
|
||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
data-session-id={rowProps.sessionId}
|
||||
onClick={() => selectSession(rowProps.sessionId)}
|
||||
title={title()}
|
||||
role="button"
|
||||
aria-selected={isActive()}
|
||||
aria-expanded={rowProps.hasChildren ? Boolean(rowProps.expanded) : undefined}
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="session-item-title truncate">{title()}</span>
|
||||
{rowProps.isChild ? (
|
||||
<Bot class="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<User class="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||
</div>
|
||||
<Show when={rowProps.canClose}>
|
||||
<span
|
||||
class="session-item-close opacity-80 hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onClose(rowProps.sessionId)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close session"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{pendingPermission() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{statusText()}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={rowProps.hasChildren && !rowProps.isChild}
|
||||
fallback={
|
||||
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
rowProps.onToggleExpand?.()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"}
|
||||
title={rowProps.expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{pendingPermission() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{statusText()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="session-item-actions">
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
@@ -234,38 +287,69 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const userSessionIds = createMemo(
|
||||
() => {
|
||||
const ids: string[] = []
|
||||
for (const session of props.sessions.values()) {
|
||||
if (session.parentId === null) {
|
||||
ids.push(session.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
},
|
||||
undefined,
|
||||
{ equals: arraysEqual },
|
||||
)
|
||||
|
||||
const childSessionIds = createMemo(
|
||||
() => {
|
||||
const children: { id: string; updated: number }[] = []
|
||||
for (const session of props.sessions.values()) {
|
||||
if (session.parentId !== null) {
|
||||
children.push({ id: session.id, updated: session.time.updated ?? 0 })
|
||||
}
|
||||
}
|
||||
if (children.length <= 1) {
|
||||
return children.map((entry) => entry.id)
|
||||
}
|
||||
children.sort((a, b) => b.updated - a.updated)
|
||||
return children.map((entry) => entry.id)
|
||||
},
|
||||
undefined,
|
||||
{ equals: arraysEqual },
|
||||
)
|
||||
const activeParentId = createMemo(() => {
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return null
|
||||
|
||||
const activeSession = props.sessions.get(activeId)
|
||||
if (!activeSession) return null
|
||||
|
||||
return activeSession.parentId ?? activeSession.id
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const parentId = activeParentId()
|
||||
if (!parentId) return
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
})
|
||||
|
||||
const listEl = createSignal<HTMLElement | null>(null)
|
||||
|
||||
const escapeCss = (value: string) => {
|
||||
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
|
||||
return (CSS as any).escape(value)
|
||||
}
|
||||
return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"")
|
||||
}
|
||||
|
||||
const scrollActiveIntoView = (sessionId: string) => {
|
||||
const root = listEl[0]()
|
||||
if (!root) return
|
||||
|
||||
const selector = `[data-session-id="${escapeCss(sessionId)}"]`
|
||||
|
||||
const scrollNow = () => {
|
||||
const target = root.querySelector(selector) as HTMLElement | null
|
||||
if (!target) return
|
||||
target.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
}
|
||||
|
||||
if (typeof requestAnimationFrame === "undefined") {
|
||||
scrollNow()
|
||||
return
|
||||
}
|
||||
|
||||
// Wait a couple frames so expand/collapse DOM settles.
|
||||
let raf1 = 0
|
||||
let raf2 = 0
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
scrollNow()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (raf1) cancelAnimationFrame(raf1)
|
||||
if (raf2) cancelAnimationFrame(raf2)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return
|
||||
scrollActiveIntoView(activeId)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||
@@ -283,46 +367,34 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="session-list flex-1 overflow-y-auto">
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
Instance
|
||||
</div>
|
||||
<div class="session-list-item group">
|
||||
<button
|
||||
class={`session-item-base ${props.activeSessionId === "info" ? "session-item-active" : "session-item-inactive"}`}
|
||||
onClick={() => selectSession("info")}
|
||||
title="Instance Info"
|
||||
role="button"
|
||||
aria-selected={props.activeSessionId === "info"}
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
<Info class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="session-item-title truncate">Instance Info</span>
|
||||
</div>
|
||||
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
|
||||
<Show when={props.threads.length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={props.threads}>
|
||||
|
||||
<Show when={userSessionIds().length > 0}>
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
User Session
|
||||
</div>
|
||||
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
{(thread) => {
|
||||
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
|
||||
<Show when={childSessionIds().length > 0}>
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
Agent Sessions
|
||||
</div>
|
||||
<For each={childSessionIds()}>{(id) => <SessionRow sessionId={id} />}</For>
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,7 @@ import type { Preferences, ExpansionPreference } from "../../stores/preferences"
|
||||
import { createCommandRegistry, type Command } from "../commands"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
getSessionFamily,
|
||||
getSessions,
|
||||
setActiveSession,
|
||||
} from "../../stores/sessions"
|
||||
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { MessageRecord } from "../../stores/message-v2/types"
|
||||
@@ -186,15 +180,16 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
action: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return
|
||||
const familySessions = getSessionFamily(instanceId, parentId)
|
||||
const ids = familySessions.map((s) => s.id).concat(["info"])
|
||||
const ids = getVisibleSessionIds(instanceId)
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) {
|
||||
setActiveSession(instanceId, ids[next])
|
||||
|
||||
const currentActiveId = activeSessionIdForInstance() ?? ""
|
||||
const currentIndex = ids.indexOf(currentActiveId)
|
||||
const targetIndex = (currentIndex + 1 + ids.length) % ids.length
|
||||
|
||||
const targetSessionId = ids[targetIndex]
|
||||
if (targetSessionId) {
|
||||
setActiveSessionFromList(instanceId, targetSessionId)
|
||||
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
|
||||
}
|
||||
},
|
||||
@@ -210,15 +205,17 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
action: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return
|
||||
const familySessions = getSessionFamily(instanceId, parentId)
|
||||
const ids = familySessions.map((s) => s.id).concat(["info"])
|
||||
const ids = getVisibleSessionIds(instanceId)
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) {
|
||||
setActiveSession(instanceId, ids[prev])
|
||||
|
||||
const currentActiveId = activeSessionIdForInstance() ?? ""
|
||||
const currentIndex = ids.indexOf(currentActiveId)
|
||||
const targetIndex =
|
||||
currentIndex === -1 ? ids.length - 1 : currentIndex <= 0 ? ids.length - 1 : currentIndex - 1
|
||||
|
||||
const targetSessionId = ids[targetIndex]
|
||||
if (targetSessionId) {
|
||||
setActiveSessionFromList(instanceId, targetSessionId)
|
||||
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import { getSessionFamily, activeSessionId, setActiveSession, activeParentSessionId } from "../../stores/sessions"
|
||||
import { activeSessionId, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||
|
||||
export function registerNavigationShortcuts() {
|
||||
const isMac = () => navigator.platform.toLowerCase().includes("mac")
|
||||
|
||||
const buildNavigationOrder = (instanceId: string): string[] => {
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return []
|
||||
|
||||
const familySessions = getSessionFamily(instanceId, parentId)
|
||||
if (familySessions.length === 0) return []
|
||||
|
||||
const [parentSession, ...childSessions] = familySessions
|
||||
if (!parentSession) return []
|
||||
|
||||
const sortedChildren = childSessions.slice().sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
return [parentSession.id, "info", ...sortedChildren.map((session) => session.id)]
|
||||
}
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "instance-prev",
|
||||
@@ -58,20 +45,23 @@ export function registerNavigationShortcuts() {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
|
||||
const navigationIds = buildNavigationOrder(instanceId)
|
||||
const navigationIds = getVisibleSessionIds(instanceId)
|
||||
if (navigationIds.length === 0) return
|
||||
|
||||
const currentActiveId = activeSessionId().get(instanceId)
|
||||
let currentIndex = navigationIds.indexOf(currentActiveId || "")
|
||||
const currentActiveId = activeSessionId().get(instanceId) ?? ""
|
||||
const currentIndex = navigationIds.indexOf(currentActiveId)
|
||||
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = navigationIds.length - 1
|
||||
}
|
||||
const targetIndex =
|
||||
currentIndex === -1
|
||||
? navigationIds.length - 1
|
||||
: currentIndex <= 0
|
||||
? navigationIds.length - 1
|
||||
: currentIndex - 1
|
||||
|
||||
const targetIndex = currentIndex <= 0 ? navigationIds.length - 1 : currentIndex - 1
|
||||
const targetSessionId = navigationIds[targetIndex]
|
||||
|
||||
setActiveSession(instanceId, targetSessionId)
|
||||
if (targetSessionId) {
|
||||
setActiveSessionFromList(instanceId, targetSessionId)
|
||||
}
|
||||
},
|
||||
description: "previous session",
|
||||
context: "global",
|
||||
@@ -85,20 +75,17 @@ export function registerNavigationShortcuts() {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
|
||||
const navigationIds = buildNavigationOrder(instanceId)
|
||||
const navigationIds = getVisibleSessionIds(instanceId)
|
||||
if (navigationIds.length === 0) return
|
||||
|
||||
const currentActiveId = activeSessionId().get(instanceId)
|
||||
let currentIndex = navigationIds.indexOf(currentActiveId || "")
|
||||
const currentActiveId = activeSessionId().get(instanceId) ?? ""
|
||||
const currentIndex = navigationIds.indexOf(currentActiveId)
|
||||
const targetIndex = (currentIndex + 1 + navigationIds.length) % navigationIds.length
|
||||
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0
|
||||
}
|
||||
|
||||
const targetIndex = (currentIndex + 1) % navigationIds.length
|
||||
const targetSessionId = navigationIds[targetIndex]
|
||||
|
||||
setActiveSession(instanceId, targetSessionId)
|
||||
if (targetSessionId) {
|
||||
setActiveSessionFromList(instanceId, targetSessionId)
|
||||
}
|
||||
},
|
||||
description: "next session",
|
||||
context: "global",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { batch, createSignal } from "solid-js"
|
||||
|
||||
import type { Session, SessionStatus, Agent, Provider } from "../types/session"
|
||||
import { deleteSession, loadMessages } from "./session-api"
|
||||
@@ -23,6 +23,12 @@ export interface SessionInfo {
|
||||
contextAvailableTokens: number | null
|
||||
}
|
||||
|
||||
export type SessionThread = {
|
||||
parent: Session
|
||||
children: Session[]
|
||||
latestUpdated: number
|
||||
}
|
||||
|
||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
@@ -40,6 +46,8 @@ const [loading, setLoading] = createSignal({
|
||||
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
||||
|
||||
const [expandedSessionParents, setExpandedSessionParents] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
|
||||
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
|
||||
|
||||
type InstanceIndicatorCounts = {
|
||||
@@ -375,6 +383,140 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] {
|
||||
return [parent, ...children]
|
||||
}
|
||||
|
||||
function getSessionThreads(instanceId: string): SessionThread[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions || instanceSessions.size === 0) return []
|
||||
|
||||
const parents: Session[] = []
|
||||
const childrenByParent = new Map<string, Session[]>()
|
||||
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (session.parentId === null) {
|
||||
parents.push(session)
|
||||
continue
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) continue
|
||||
const children = childrenByParent.get(parentId)
|
||||
if (children) {
|
||||
children.push(session)
|
||||
} else {
|
||||
childrenByParent.set(parentId, [session])
|
||||
}
|
||||
}
|
||||
|
||||
const threads: SessionThread[] = []
|
||||
|
||||
for (const parent of parents) {
|
||||
const children = childrenByParent.get(parent.id) ?? []
|
||||
if (children.length > 1) {
|
||||
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
|
||||
}
|
||||
|
||||
const parentUpdated = parent.time.updated ?? 0
|
||||
const latestChild = children[0]?.time.updated ?? 0
|
||||
const latestUpdated = Math.max(parentUpdated, latestChild)
|
||||
|
||||
threads.push({ parent, children, latestUpdated })
|
||||
}
|
||||
|
||||
threads.sort((a, b) => {
|
||||
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
|
||||
const bParentUpdated = b.parent.time.updated ?? 0
|
||||
const aParentUpdated = a.parent.time.updated ?? 0
|
||||
if (bParentUpdated !== aParentUpdated) return bParentUpdated - aParentUpdated
|
||||
return b.parent.id.localeCompare(a.parent.id)
|
||||
})
|
||||
|
||||
return threads
|
||||
}
|
||||
|
||||
function isSessionParentExpanded(instanceId: string, parentSessionId: string): boolean {
|
||||
return Boolean(expandedSessionParents().get(instanceId)?.has(parentSessionId))
|
||||
}
|
||||
|
||||
function setSessionParentExpanded(instanceId: string, parentSessionId: string, expanded: boolean): void {
|
||||
setExpandedSessionParents((prev) => {
|
||||
const next = new Map(prev)
|
||||
const currentSet = next.get(instanceId) ?? new Set<string>()
|
||||
const updated = new Set(currentSet)
|
||||
|
||||
if (expanded) {
|
||||
updated.add(parentSessionId)
|
||||
} else {
|
||||
updated.delete(parentSessionId)
|
||||
}
|
||||
|
||||
if (updated.size === 0) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.set(instanceId, updated)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleSessionParentExpanded(instanceId: string, parentSessionId: string): void {
|
||||
setExpandedSessionParents((prev) => {
|
||||
const next = new Map(prev)
|
||||
const currentSet = next.get(instanceId) ?? new Set<string>()
|
||||
const updated = new Set(currentSet)
|
||||
|
||||
if (updated.has(parentSessionId)) {
|
||||
updated.delete(parentSessionId)
|
||||
} else {
|
||||
updated.add(parentSessionId)
|
||||
}
|
||||
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function ensureSessionParentExpanded(instanceId: string, parentSessionId: string): void {
|
||||
if (isSessionParentExpanded(instanceId, parentSessionId)) return
|
||||
setSessionParentExpanded(instanceId, parentSessionId, true)
|
||||
}
|
||||
|
||||
function getVisibleSessionIds(instanceId: string): string[] {
|
||||
const threads = getSessionThreads(instanceId)
|
||||
if (threads.length === 0) return []
|
||||
|
||||
const expanded = expandedSessionParents().get(instanceId)
|
||||
const ids: string[] = []
|
||||
|
||||
for (const thread of threads) {
|
||||
ids.push(thread.parent.id)
|
||||
if (expanded?.has(thread.parent.id)) {
|
||||
for (const child of thread.children) {
|
||||
ids.push(child.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
function setActiveSessionFromList(instanceId: string, sessionId: string): void {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (session.parentId === null) {
|
||||
setActiveParentSession(instanceId, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) return
|
||||
|
||||
batch(() => {
|
||||
setActiveParentSession(instanceId, parentId)
|
||||
setActiveSession(instanceId, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return false
|
||||
@@ -530,6 +672,13 @@ export {
|
||||
getParentSessions,
|
||||
getChildSessions,
|
||||
getSessionFamily,
|
||||
getSessionThreads,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
setSessionParentExpanded,
|
||||
toggleSessionParentExpanded,
|
||||
ensureSessionParentExpanded,
|
||||
setActiveSessionFromList,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
getSessionInfo,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
clearActiveParentSession,
|
||||
clearInstanceDraftPrompts,
|
||||
clearSessionDraftPrompt,
|
||||
ensureSessionParentExpanded,
|
||||
getActiveParentSession,
|
||||
getActiveSession,
|
||||
getChildSessions,
|
||||
@@ -16,18 +17,24 @@ import {
|
||||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
getSessions,
|
||||
getVisibleSessionIds,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
isSessionParentExpanded,
|
||||
loading,
|
||||
providers,
|
||||
sessionInfoByInstance,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setActiveSessionFromList,
|
||||
setSessionDraftPrompt,
|
||||
setSessionParentExpanded,
|
||||
setSessionStatus,
|
||||
} from "./session-state"
|
||||
toggleSessionParentExpanded,
|
||||
} from "./session-state"
|
||||
|
||||
import { getDefaultModel } from "./session-models"
|
||||
import {
|
||||
@@ -85,6 +92,7 @@ export {
|
||||
clearSessionDraftPrompt,
|
||||
createSession,
|
||||
deleteSession,
|
||||
ensureSessionParentExpanded,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
@@ -100,9 +108,12 @@ export {
|
||||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
getSessions,
|
||||
getVisibleSessionIds,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
isSessionParentExpanded,
|
||||
loadMessages,
|
||||
loading,
|
||||
providers,
|
||||
@@ -111,8 +122,11 @@ export {
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setActiveSessionFromList,
|
||||
setSessionDraftPrompt,
|
||||
setSessionParentExpanded,
|
||||
setSessionStatus,
|
||||
toggleSessionParentExpanded,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@
|
||||
|
||||
.permission-center-item-active {
|
||||
border-color: var(--session-status-permission-fg);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--session-status-permission-fg) 35%, transparent);
|
||||
}
|
||||
|
||||
.permission-center-item-header {
|
||||
@@ -217,6 +216,13 @@
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Remove border from tool-call when inside permission items to avoid double borders */
|
||||
.permission-center-item .tool-call {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.permission-center-modal-backdrop {
|
||||
padding: 0;
|
||||
@@ -228,4 +234,4 @@
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,214 +483,3 @@
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
/* Session view utility */
|
||||
.session-view {
|
||||
@apply flex flex-1 min-h-0 flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Session list component */
|
||||
.session-list-container {
|
||||
@apply flex flex-col flex-1 min-h-0 relative;
|
||||
background-color: var(--surface-secondary);
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.session-sidebar {
|
||||
@apply flex flex-col min-h-0;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-header {
|
||||
@apply flex flex-col gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-sidebar-shortcuts {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.session-sidebar-new {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-controls {
|
||||
@apply flex flex-col gap-3;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-controls > * {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-separator {
|
||||
background-color: var(--border-base);
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.session-resize-handle--left {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.session-resize-handle--right {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.session-resize-handle:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.session-resize-handle::before {
|
||||
content: "";
|
||||
@apply absolute top-0 h-full w-2;
|
||||
}
|
||||
|
||||
.session-resize-handle--left::before {
|
||||
right: 0;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.session-resize-handle--right::before {
|
||||
left: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
@apply border-b relative;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-list-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.session-list-item {
|
||||
@apply border-b last:border-b-0;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-item-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-item-row {
|
||||
@apply flex items-center gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-item-header {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.session-item-title-row {
|
||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
@apply justify-between items-center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.session-item-active .session-item-meta {
|
||||
color: var(--text-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-item-actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.session-item-active {
|
||||
background-color: var(--list-item-highlight-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.session-item-inactive {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-item-inactive:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-active .session-item-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
@apply flex-1 min-w-0;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.session-item-close {
|
||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
||||
}
|
||||
|
||||
.session-item-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.session-list-footer {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button {
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-new-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
/* Responsive behavior for session list */
|
||||
@media (max-width: 768px) {
|
||||
.session-list-container {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,58 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
|
||||
.session-item-base.session-item-child {
|
||||
padding-left: 2.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-item-base.session-item-child::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 1.125rem;
|
||||
width: 1px;
|
||||
background-color: var(--text-secondary);
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.session-item-base.session-item-child.session-item-child-last::before {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.session-item-base.session-item-child::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 1.125rem;
|
||||
width: 0.875rem;
|
||||
height: 1px;
|
||||
background-color: var(--text-secondary);
|
||||
opacity: 0.95;
|
||||
transform: translateY(-0.5px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.session-item-base.session-item-border-user {
|
||||
border-left: 4px solid var(--message-user-border);
|
||||
}
|
||||
|
||||
.session-item-base.session-item-border-assistant {
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.session-item-expander {
|
||||
@apply flex-shrink-0 p-0.5 rounded transition-colors;
|
||||
}
|
||||
|
||||
.session-item-expander--spacer {
|
||||
width: calc(0.875rem + 0.25rem);
|
||||
height: calc(0.875rem + 0.25rem);
|
||||
}
|
||||
|
||||
.session-item-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
@@ -221,7 +273,7 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
}
|
||||
|
||||
.session-item-title-row {
|
||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
||||
@apply flex items-start gap-2 min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
@@ -247,6 +299,16 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.session-item-base.session-item-kind-user.session-item-active {
|
||||
background-color: var(--session-user-active-bg);
|
||||
}
|
||||
|
||||
.session-item-base.session-item-kind-assistant.session-item-active {
|
||||
background-color: var(--session-assistant-active-bg);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.session-item-inactive {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -256,6 +318,8 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.session-item-active .session-item-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
@@ -266,6 +330,14 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.session-item-title--clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.session-item-close {
|
||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
--message-tool-bg: #f8f9fa;
|
||||
--message-tool-border: #6c757d;
|
||||
|
||||
/* Session list selection tints */
|
||||
--session-user-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-user-border));
|
||||
--session-assistant-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-assistant-border));
|
||||
|
||||
/* Semantic component colors */
|
||||
--session-status-working-fg: #b45309;
|
||||
--session-status-working-bg: rgba(245, 158, 11, 0.16);
|
||||
|
||||
Reference in New Issue
Block a user