diff --git a/docs/screenshots/01-input-nav-basic.png b/docs/screenshots/01-input-nav-basic.png new file mode 100644 index 000000000000..1fbe52025454 Binary files /dev/null and b/docs/screenshots/01-input-nav-basic.png differ diff --git a/docs/screenshots/02-input-nav-hover-medium.png b/docs/screenshots/02-input-nav-hover-medium.png new file mode 100644 index 000000000000..44bc12b18233 Binary files /dev/null and b/docs/screenshots/02-input-nav-hover-medium.png differ diff --git a/docs/screenshots/02-input-nav-hover-text.png b/docs/screenshots/02-input-nav-hover-text.png new file mode 100644 index 000000000000..86941b8d9db9 Binary files /dev/null and b/docs/screenshots/02-input-nav-hover-text.png differ diff --git a/docs/screenshots/03-input-nav-hover-files.png b/docs/screenshots/03-input-nav-hover-files.png new file mode 100644 index 000000000000..e31afad6e1c9 Binary files /dev/null and b/docs/screenshots/03-input-nav-hover-files.png differ diff --git a/docs/screenshots/03-input-nav-hover-with-files.png b/docs/screenshots/03-input-nav-hover-with-files.png new file mode 100644 index 000000000000..53dc0211c00c Binary files /dev/null and b/docs/screenshots/03-input-nav-hover-with-files.png differ diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a20e66de3ee3..913205e6f86e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -450,6 +450,18 @@ export const SettingsGeneral: Component = () => { /> + + +
+ settings.general.setShowInputNav(checked)} + /> +
+
) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index e50bd316c9ab..9e34f765c903 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -438,6 +438,18 @@ export const SettingsGeneralV2: Component<{ /> + + +
+ settings.general.setShowInputNav(checked)} + /> +
+
) diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts index 3f94f22ec3ff..18771fd6ac5b 100644 --- a/packages/app/src/context/settings.test.ts +++ b/packages/app/src/context/settings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { + defaultSettings, hasExistingWebState, isAppUpgrade, layoutTransitionState, @@ -77,3 +78,9 @@ describe("layout transition", () => { expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false) }) }) + +describe("showInputNav", () => { + test("defaults to false", () => { + expect(defaultSettings.general.showInputNav).toBe(false) + }) +}) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index fe8b4e3c03f7..7fbb399f0b42 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -33,6 +33,7 @@ export interface Settings { shellToolPartsExpanded: boolean editToolPartsExpanded: boolean showCustomAgents: boolean + showInputNav: boolean mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean layoutTransitionEligible?: boolean @@ -174,7 +175,7 @@ export function terminalFontFamily(font: string | undefined) { return stack(font, terminalBase) } -const defaultSettings: Settings = { +export const defaultSettings: Settings = { general: { autoSave: true, releaseNotes: true, @@ -188,6 +189,7 @@ const defaultSettings: Settings = { shellToolPartsExpanded: false, editToolPartsExpanded: false, showCustomAgents: false, + showInputNav: false, mobileTitlebarPosition: "top", }, appearance: { @@ -406,6 +408,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setShowCustomAgents(value: boolean) { setStore("general", "showCustomAgents", value) }, + showInputNav: withFallback(() => store.general?.showInputNav, defaultSettings.general.showInputNav), + setShowInputNav(value: boolean) { + setStore("general", "showInputNav", value) + }, mobileTitlebarPosition: withFallback( () => store.general?.mobileTitlebarPosition, defaultSettings.general.mobileTitlebarPosition, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f03147f151d1..f21b529b8d2a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -895,6 +895,9 @@ export const dict = { "settings.general.row.showCustomAgents.title": "Show agent", "settings.general.row.showCustomAgents.description": "Switch between agents in the composer. When hidden, defaults to Build agent.", + "settings.general.row.showInputNav.title": "Input navigation", + "settings.general.row.showInputNav.description": + "Show a floating navigation rail for user messages on the right side of the session panel", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c6e3a5fcbb71..9245154ffe46 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -86,6 +86,7 @@ import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" +import { InputNav } from "@opencode-ai/session-ui/input-nav" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" @@ -2278,6 +2279,15 @@ export default function Page() { )} + + sync().data.part[messageID] ?? []} + current={visibleUserMessages().at(-1)} + onMessageSelect={(message) => scrollToMessage(message)} + /> + +
size.start()}> p.type === "text" && !(p as { synthetic?: boolean }).synthetic, + ) + const fileParts = parts.filter((p): p is Part & { type: "file"; mime: string; url: string } => p.type === "file") + + const images = fileParts + .filter((p) => p.mime.startsWith("image/")) + .map((p) => ({ filename: (p as { filename?: string }).filename, url: p.url })) + + const files = fileParts + .filter((p) => !p.mime.startsWith("image/")) + .map((p) => ({ + filename: (p as { filename?: string }).filename, + url: p.url, + mime: p.mime, + })) + + let text: string | undefined + if (textPart) { + text = + textPart.text.length > PREVIEW_TEXT_MAX_LENGTH + ? textPart.text.slice(0, PREVIEW_TEXT_MAX_LENGTH) + "..." + : textPart.text + } + + return { text, images, files } +} diff --git a/packages/session-ui/src/components/input-nav.css b/packages/session-ui/src/components/input-nav.css new file mode 100644 index 000000000000..fe632b10005a --- /dev/null +++ b/packages/session-ui/src/components/input-nav.css @@ -0,0 +1,108 @@ +[data-component="input-nav"] { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 4px; + z-index: 5; + user-select: none; +} + +[data-slot="input-nav-item"] { + display: flex; + align-items: center; + justify-content: flex-end; + height: 3px; + cursor: pointer; +} + +[data-slot="input-nav-bar"] { + height: 100%; + border-radius: 2px; + background-color: var(--border-base); + transition: + background-color 0.2s, + width 0.2s; +} + +[data-slot="input-nav-bar"]:hover { + background-color: var(--text-base); +} + +[data-slot="input-nav-bar"][data-active] { + background-color: var(--color-brand-500); +} + +[data-slot="input-nav-preview-content"] { + z-index: 1000; + max-width: 300px; + max-height: min(400px, calc(100vh - 6rem)); + overflow-y: auto; + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--surface-raised-stronger-non-alpha); + box-shadow: + 0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)), + 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), + 0 1px 3px 0 rgba(19, 16, 16, 0.08); +} + +[data-slot="input-nav-preview"] { + display: flex; + flex-direction: column; + gap: 8px; +} + +[data-slot="input-nav-preview-text"] { + font-size: 13px; + line-height: 1.5; + color: var(--text-base); + white-space: pre-wrap; + word-break: break-word; +} + +[data-slot="input-nav-preview-images"] { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +[data-slot="input-nav-preview-image"] { + max-width: 100%; + max-height: 150px; + border-radius: var(--radius-sm); + object-fit: contain; +} + +[data-slot="input-nav-preview-files"] { + display: flex; + flex-direction: column; + gap: 4px; +} + +[data-slot="input-nav-preview-file"] { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-base); +} + +[data-slot="input-nav-preview-file-icon"] { + flex-shrink: 0; +} + +[data-slot="input-nav-preview-file-name"] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-slot="input-nav-preview-empty"] { + font-size: 13px; + color: var(--text-tertiary); +} diff --git a/packages/session-ui/src/components/input-nav.stories.tsx b/packages/session-ui/src/components/input-nav.stories.tsx new file mode 100644 index 000000000000..373a47620895 --- /dev/null +++ b/packages/session-ui/src/components/input-nav.stories.tsx @@ -0,0 +1,91 @@ +// @ts-nocheck +import { InputNav } from "./input-nav" +import type { UserMessage, Part } from "@opencode-ai/sdk/v2" + +const mockMessages: UserMessage[] = [ + { + id: "msg-1", + sessionID: "session-1", + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-4" }, + }, + { + id: "msg-2", + sessionID: "session-1", + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-4" }, + }, + { + id: "msg-3", + sessionID: "session-1", + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-4" }, + }, +] + +const mockParts: Record = { + "msg-1": [ + { id: "p1", sessionID: "session-1", messageID: "msg-1", type: "text", text: "Short" } as Part, + ], + "msg-2": [ + { + id: "p2", + sessionID: "session-1", + messageID: "msg-2", + type: "text", + text: "This is a medium length message that contains more content than the first one but less than the third message in this conversation.", + } as Part, + ], + "msg-3": [ + { + id: "p3", + sessionID: "session-1", + messageID: "msg-3", + type: "text", + text: "This is a very long message with lots of detailed information about the task at hand. It contains multiple paragraphs and extensive context that the AI needs to understand in order to provide a helpful response. The width of the navigation bar should be proportional to this content length, making it visibly wider than the shorter messages above.", + } as Part, + { id: "p4", sessionID: "session-1", messageID: "msg-3", type: "file", mime: "image/png", filename: "screenshot.png", url: "https://via.placeholder.com/150" } as Part, + { id: "p5", sessionID: "session-1", messageID: "msg-3", type: "file", mime: "application/pdf", filename: "document.pdf", url: "#" } as Part, + ], +} + +export default { + title: "UI/InputNav", + component: InputNav, +} + +export const Basic = { + args: { + messages: mockMessages, + getParts: (messageID: string) => mockParts[messageID] ?? [], + current: mockMessages[1], + onMessageSelect: (msg: UserMessage) => console.log("Selected:", msg.id), + }, + decorators: [ + (Story: any) => ( +
+
+

Session content area

+

Hover the bars on the right to see previews

+
+ +
+ ), + ], +} + +export const WithoutCurrent = { + args: { + messages: mockMessages, + getParts: (messageID: string) => mockParts[messageID] ?? [], + current: undefined, + onMessageSelect: (msg: UserMessage) => console.log("Selected:", msg.id), + }, + decorators: Basic.decorators, +} diff --git a/packages/session-ui/src/components/input-nav.test.ts b/packages/session-ui/src/components/input-nav.test.ts new file mode 100644 index 000000000000..7b574b8ad8a7 --- /dev/null +++ b/packages/session-ui/src/components/input-nav.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test" +import { computeBarWidth, extractPreviewContent } from "./input-nav-utils" +import type { Part } from "@opencode-ai/sdk/v2" + +describe("computeBarWidth", () => { + test("returns min width for empty text", () => { + expect(computeBarWidth("")).toBe(4) + }) + + test("returns min width for very short text", () => { + expect(computeBarWidth("hi")).toBe(4) + }) + + test("scales width proportionally to text length", () => { + const short = computeBarWidth("hello world") + const long = computeBarWidth("a".repeat(500)) + expect(long).toBeGreaterThan(short) + }) + + test("caps at max width for very long text", () => { + const result = computeBarWidth("a".repeat(10000)) + expect(result).toBe(24) + }) + + test("returns integer widths", () => { + expect(Number.isInteger(computeBarWidth("test"))).toBe(true) + expect(Number.isInteger(computeBarWidth("a".repeat(300)))).toBe(true) + }) +}) + +describe("extractPreviewContent", () => { + const makeTextPart = (text: string): Part => ({ + id: "p1", + sessionID: "s1", + messageID: "m1", + type: "text", + text, + }) as Part + + const makeFilePart = (mime: string, filename?: string, url = "http://example.com/file"): Part => ({ + id: "p2", + sessionID: "s1", + messageID: "m1", + type: "file", + mime, + filename, + url, + }) as Part + + test("extracts text from TextPart", () => { + const parts = [makeTextPart("Hello world")] + const result = extractPreviewContent(parts) + expect(result.text).toBe("Hello world") + expect(result.images).toHaveLength(0) + expect(result.files).toHaveLength(0) + }) + + test("truncates long text to 200 characters", () => { + const longText = "a".repeat(300) + const parts = [makeTextPart(longText)] + const result = extractPreviewContent(parts) + expect(result.text?.length).toBe(203) // 200 + "..." + expect(result.text?.endsWith("...")).toBe(true) + }) + + test("skips synthetic text parts", () => { + const synthetic = { ...makeTextPart("synthetic"), synthetic: true } as Part + const real = makeTextPart("real content") + const parts = [synthetic, real] + const result = extractPreviewContent(parts) + expect(result.text).toBe("real content") + }) + + test("extracts image files", () => { + const parts = [makeFilePart("image/png", "screenshot.png")] + const result = extractPreviewContent(parts) + expect(result.images).toHaveLength(1) + expect(result.images[0].filename).toBe("screenshot.png") + }) + + test("extracts non-image files", () => { + const parts = [makeFilePart("application/pdf", "document.pdf")] + const result = extractPreviewContent(parts) + expect(result.files).toHaveLength(1) + expect(result.files[0].filename).toBe("document.pdf") + }) + + test("separates images from other files", () => { + const parts = [ + makeFilePart("image/png", "photo.png"), + makeFilePart("text/plain", "readme.txt"), + makeFilePart("image/jpeg", "diagram.jpg"), + ] + const result = extractPreviewContent(parts) + expect(result.images).toHaveLength(2) + expect(result.files).toHaveLength(1) + }) + + test("returns empty content for empty parts", () => { + const result = extractPreviewContent([]) + expect(result.text).toBeUndefined() + expect(result.images).toHaveLength(0) + expect(result.files).toHaveLength(0) + }) +}) diff --git a/packages/session-ui/src/components/input-nav.tsx b/packages/session-ui/src/components/input-nav.tsx new file mode 100644 index 000000000000..02fa1902fa5c --- /dev/null +++ b/packages/session-ui/src/components/input-nav.tsx @@ -0,0 +1,109 @@ +import { HoverCard } from "@kobalte/core/hover-card" +import { ComponentProps, For, Show, createSignal, splitProps } from "solid-js" +import type { Part, UserMessage } from "@opencode-ai/sdk/v2" +import { computeBarWidth, extractPreviewContent, type PreviewContent } from "./input-nav-utils" +import "./input-nav.css" + +export function InputNav( + props: ComponentProps<"div"> & { + messages: UserMessage[] + getParts: (messageID: string) => Part[] + current?: UserMessage + onMessageSelect: (message: UserMessage) => void + }, +) { + const [local, others] = splitProps(props, ["messages", "getParts", "current", "onMessageSelect", "class"]) + const [hoveredIndex, setHoveredIndex] = createSignal(null) + + return ( +
+ + {(message, index) => { + const isActive = () => message.id === local.current?.id + const barWidth = () => { + const parts = local.getParts(message.id) + const textPart = parts.find( + (p): p is Part & { type: "text"; text: string } => + p.type === "text" && !(p as { synthetic?: boolean }).synthetic, + ) + return computeBarWidth(textPart?.text ?? "") + } + + const handleClick = () => local.onMessageSelect(message) + + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + local.onMessageSelect(message) + } + + const preview = () => extractPreviewContent(local.getParts(message.id)) + + return ( + setHoveredIndex(open ? index() : null)} + openDelay={200} + closeDelay={100} + placement="left" + gutter={8} + overflowPadding={24} + fitViewport + > + +
+ + + + + + + + ) + }} + +
+ ) +} + +function PreviewPanel(props: { preview: PreviewContent }) { + return ( +
+ +
{props.preview.text}
+
+ 0}> +
+ + {(img) => ( + {img.filename + )} + +
+
+ 0}> +
+ + {(file) => ( +
+ 📄 + {file.filename ?? file.mime} +
+ )} +
+
+
+ +
No preview available
+
+
+ ) +} diff --git a/test-page.html b/test-page.html new file mode 100644 index 000000000000..cc92ed4679a9 --- /dev/null +++ b/test-page.html @@ -0,0 +1,212 @@ + + + + + InputNav Component Test + + + +

InputNav Component - Session Panel

+
+
+
+
You
+
Hello, please help me understand how to build a React component.
+
+
+
Assistant
+
Sure! I can help you build a React component. What kind of component are you looking to create?
+
+
+
You
+
I need a form component with validation. It should have fields for name, email, and password.
+
+
+
Assistant
+
Here is a React form component with validation for name, email, and password fields.
+
+
+
You
+
Can you also add error messages that display below each field when validation fails?
+
+
+
Assistant
+
I have updated the component to include error messages below each field.
+
+
+
You
+
Great! Now please add a submit button that is disabled until all fields are valid. Also add a loading state while the form is being submitted. When the form is submitted successfully, show a success message and reset the form.
+
+
+
Assistant
+
Here is the updated component with a disabled submit button, loading state, and success message.
+
+
+ + +
+ + + + +
+
+ +