diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 29a3666c034..4619d1765f5 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" +import { formatSessionTitle } from "@/utils/session-title" import { getRelativeTime } from "@/utils/time" type EntryType = "command" | "file" | "session" @@ -206,7 +207,7 @@ function createSessionEntries(props: { .filter((s) => !!s?.id) .map((s) => ({ id: s.id, - title: s.title ?? props.language.t("command.session.new"), + title: formatSessionTitle(s.title ?? "") || props.language.t("command.session.new"), description, directory, archived: s.time?.archived, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cb194052d1e..7c8c03455ea 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -18,6 +18,7 @@ import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" +import { formatSessionTitle } from "@/utils/session-title" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -373,7 +374,7 @@ export default function Layout(props: ParentProps) { const session = store.session.find((s) => s.id === props.sessionID) const sessionKey = `${directory}:${props.sessionID}` - const sessionTitle = session?.title ?? language.t("command.session.new") + const sessionTitle = formatSessionTitle(session?.title ?? "") || language.t("command.session.new") const projectName = getFilename(directory) const description = e.details.type === "permission.asked" diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 194f75f815b..68bc1017353 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -16,6 +16,7 @@ import { getFilename } from "@opencode-ai/util/path" import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client" import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" import { agentColor } from "@/utils/agent" +import { formatSessionTitle } from "@/utils/session-title" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -122,7 +123,7 @@ const SessionRow = (props: { - {props.session.title} + {formatSessionTitle(props.session.title)} {(summary) => ( @@ -280,7 +281,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { + {item} } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b8410903550..4dded9bb2b6 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -12,6 +12,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { formatSessionTitle } from "@/utils/session-title" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" @@ -404,7 +405,7 @@ export function MessageTimeline(props: { class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" onDblClick={openTitleEditor} > - {titleValue()} + {formatSessionTitle(titleValue() ?? "")} } > diff --git a/packages/app/src/utils/session-title.ts b/packages/app/src/utils/session-title.ts new file mode 100644 index 00000000000..1c3f4277019 --- /dev/null +++ b/packages/app/src/utils/session-title.ts @@ -0,0 +1,9 @@ +export function formatSessionTitle(title: string): string { + const pipeIndex = title.indexOf("|") + if (pipeIndex === -1) return title + const group = title.slice(0, pipeIndex).trim() + const rest = title.slice(pipeIndex + 1).trim() + if (!group) return rest + const capitalized = group.charAt(0).toUpperCase() + group.slice(1) + return `${capitalized}: ${rest}` +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb3..23a9dff592e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -34,29 +34,89 @@ export function DialogSessionList() { const sessions = createMemo(() => searchResults() ?? sync.data.session) + function parseSessionTitle(title: string): { group?: string; displayTitle: string } { + const pipeIndex = title.indexOf("|") + if (pipeIndex === -1) { + return { displayTitle: title } + } + + const group = title.slice(0, pipeIndex).trim() + const displayTitle = title.slice(pipeIndex + 1).trim() + + if (!group) { + return { displayTitle } + } + + const capitalized = group.charAt(0).toUpperCase() + group.slice(1) + return { group: capitalized + ":", displayTitle } + } + const options = createMemo(() => { const today = new Date().toDateString() - return sessions() - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => { - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, - } - }) + const allSessions = sessions().filter((x) => x.parentID === undefined) + + // Separate into grouped and ungrouped + const grouped: typeof allSessions = [] + const ungrouped: typeof allSessions = [] + + for (const session of allSessions) { + const parsed = parseSessionTitle(session.title) + if (parsed.group) { + grouped.push(session) + } else { + ungrouped.push(session) + } + } + + // Sort grouped by group name ASC, then updated DESC + grouped.sort((a, b) => { + const aParsed = parseSessionTitle(a.title) + const bParsed = parseSessionTitle(b.title) + const groupCompare = (aParsed.group ?? "").localeCompare(bParsed.group ?? "") + if (groupCompare !== 0) return groupCompare + return b.time.updated - a.time.updated + }) + + // Sort ungrouped by updated DESC + ungrouped.sort((a, b) => b.time.updated - a.time.updated) + + // Map grouped sessions + const groupedOptions = grouped.map((session) => { + const parsed = parseSessionTitle(session.title) + const isDeleting = toDelete() === session.id + const status = sync.data.session_status?.[session.id] + const isWorking = status?.type === "busy" + return { + title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : parsed.displayTitle, + bg: isDeleting ? theme.error : undefined, + value: session.id, + category: parsed.group, + footer: Locale.shortDateTime(session.time.updated), + gutter: isWorking ? : undefined, + } + }) + + // Map ungrouped sessions + const ungroupedOptions = ungrouped.map((session) => { + const date = new Date(session.time.updated) + let category = date.toDateString() + if (category === today) { + category = "Today" + } + const isDeleting = toDelete() === session.id + const status = sync.data.session_status?.[session.id] + const isWorking = status?.type === "busy" + return { + title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : session.title, + bg: isDeleting ? theme.error : undefined, + value: session.id, + category, + footer: Locale.time(session.time.updated), + gutter: isWorking ? : undefined, + } + }) + + return [...groupedOptions, ...ungroupedOptions] }) onMount(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 0c5ea9a8572..bbddc89b1ed 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -8,12 +8,17 @@ import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" import { useTerminalDimensions } from "@opentui/solid" +import { formatSessionTitle, parseSessionTitleParts } from "@tui/util/session-title" const Title = (props: { session: Accessor }) => { const { theme } = useTheme() + const parts = createMemo(() => parseSessionTitleParts(props.session().title)) return ( - # {props.session().title} + #{" "} + {parts().rest}}> + {parts().group} {parts().rest} + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f20267e0820..5dc42a38843 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -58,6 +58,7 @@ import type { PromptInfo } from "../../component/prompt/history" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" +import { formatSessionTitle } from "@tui/util/session-title" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { Flag } from "@/flag/flag" @@ -231,7 +232,7 @@ export function Session() { const exit = useExit() createEffect(() => { - const title = Locale.truncate(session()?.title ?? "", 50) + const title = Locale.truncate(formatSessionTitle(session()?.title ?? ""), 50) const pad = (text: string) => text.padEnd(10, " ") const weak = (text: string) => UI.Style.TEXT_DIM + pad(text) + UI.Style.TEXT_NORMAL const logo = UI.logo(" ").split(/\r?\n/) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe080..104f515779e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,11 +11,13 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { formatSessionTitle, parseSessionTitleParts } from "@tui/util/session-title" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() const session = createMemo(() => sync.session.get(props.sessionID)!) + const titleParts = createMemo(() => parseSessionTitleParts(session().title)) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) @@ -92,7 +94,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {session().title} + {titleParts().rest}}> + {titleParts().group} {titleParts().rest} + {session().share!.url} diff --git a/packages/opencode/src/cli/cmd/tui/util/session-title.ts b/packages/opencode/src/cli/cmd/tui/util/session-title.ts new file mode 100644 index 00000000000..6fea14ab908 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/session-title.ts @@ -0,0 +1,19 @@ +export function formatSessionTitle(title: string): string { + const pipeIndex = title.indexOf("|") + if (pipeIndex === -1) return title + const group = title.slice(0, pipeIndex).trim() + const rest = title.slice(pipeIndex + 1).trim() + if (!group) return rest + const capitalized = group.charAt(0).toUpperCase() + group.slice(1) + return `${capitalized}: ${rest}` +} + +export function parseSessionTitleParts(title: string): { group?: string; rest: string } { + const pipeIndex = title.indexOf("|") + if (pipeIndex === -1) return { rest: title } + const group = title.slice(0, pipeIndex).trim() + const rest = title.slice(pipeIndex + 1).trim() + if (!group) return { rest } + const capitalized = group.charAt(0).toUpperCase() + group.slice(1) + return { group: capitalized + ":", rest } +} diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 653da09a0b7..76e94dff164 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -28,6 +28,27 @@ export namespace Locale { } } + export function shortDateTime(input: number): string { + const date = new Date(input) + const now = new Date() + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate() + + const timeStr = time(input) + + if (isToday) { + return timeStr + } else { + const dateStr = date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) + return `${dateStr} ยท ${timeStr}` + } + } + export function number(num: number): string { if (num >= 1000000) { return (num / 1000000).toFixed(1) + "M"