Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"file-type": "^22.0.0",
"hast-util-to-jsx-runtime": "^2",
"html-url-attributes": "^3",
"mermaid": "^12.0.0",
"openai": "^6.34.0",
"remark-parse": "^11",
"remark-rehype": "^11",
Expand Down
57 changes: 52 additions & 5 deletions src/client/app/ChatPage/ChatTranscriptViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import { ArrowDown, Flower, Upload } from "lucide-react"
import { DrainingIndicator } from "../../components/messages/DrainingIndicator"
import { QueuedUserMessage } from "../../components/messages/QueuedUserMessage"
import { AttachmentPreviewModal } from "../../components/messages/AttachmentPreviewModal"
import { classifyAttachmentPreview, inferAttachmentPreviewMimeType } from "../../components/messages/attachmentPreview"
import { OpenLocalLinkProvider, type OpenLocalLinkTarget } from "../../components/messages/shared"
import { ProcessingMessage } from "../../components/messages/ProcessingMessage"
import { ContextMenu, ContextMenuTrigger } from "../../components/ui/context-menu"
Expand All @@ -18,7 +20,7 @@ import { TRANSCRIPT_PADDING_BOTTOM_OFFSET } from "../kannaStateHelpers"
import { useScrollbarGutterVar } from "../../hooks/useScrollbarGutterVar"
import { cn } from "../../lib/utils"
import type { ChatJumpRole } from "../../lib/chat-navigation"
import { formatPathWithTilde, shouldOpenLocalFileLinkInEditor } from "../../lib/pathUtils"
import { formatPathWithTilde, projectRelativeFilePath, shouldOpenLocalFileLinkInEditor } from "../../lib/pathUtils"
import {
buildResolvedTranscriptRows,
KannaTranscriptRow,
Expand Down Expand Up @@ -49,7 +51,8 @@ import {
EMPTY_STATE_TEXT,
} from "./utils"
import type { EditorOpenSettings, EditorPreset, OpenExternalAction } from "../../../shared/protocol"
import type { TranscriptOutlineEntry } from "../../../shared/types"
import { browserOriginFromWindow, parseBrowserAccessContext } from "../../../shared/browser-context"
import type { ChatAttachment, TranscriptOutlineEntry } from "../../../shared/types"
/**
* How close to the bottom counts as "at the end", as a fraction of viewport
* height.
Expand Down Expand Up @@ -185,6 +188,7 @@ interface ChatTranscriptViewportProps {
messages: KannaState["messages"]
queuedMessages: KannaState["queuedMessages"]
transcriptPaddingBottom: number
projectId: string | null
localPath: string | null | undefined
latestToolIds: KannaState["latestToolIds"]
isProcessing: boolean
Expand Down Expand Up @@ -391,6 +395,7 @@ const TranscriptScrollerBody = memo(function TranscriptScrollerBody({
messages,
queuedMessages,
transcriptPaddingBottom,
projectId,
localPath,
latestToolIds,
isProcessing,
Expand Down Expand Up @@ -437,6 +442,8 @@ const TranscriptScrollerBody = memo(function TranscriptScrollerBody({
const localLinkMenuTriggerRef = useRef<HTMLSpanElement | null>(null)
const [toolGroupExpanded, setToolGroupExpanded] = useState<Record<string, boolean>>({})
const [localLinkMenuTarget, setLocalLinkMenuTarget] = useState<OpenLocalLinkTarget | null>(null)
const [localFilePreview, setLocalFilePreview] = useState<ChatAttachment | null>(null)
const [localLinkError, setLocalLinkError] = useState<string | null>(null)
const isMac = platform === "darwin"

const rawRows = useMemo(() => buildResolvedTranscriptRows(messages, {
Expand Down Expand Up @@ -916,6 +923,38 @@ const TranscriptScrollerBody = memo(function TranscriptScrollerBody({

const handleOpenLocalLinkClick = useCallback((target: OpenLocalLinkTarget) => {
if (target.trigger !== "contextmenu") {
const accessContext = parseBrowserAccessContext(browserOriginFromWindow())
if (accessContext?.mode === "network") {
const relativePath = projectRelativeFilePath(target.path, localPath)
if (!projectId || !relativePath) {
setLocalLinkError(
"This file is outside the active project and can't be previewed over the network. Right-click it for actions on the Kanna machine."
)
return
}

const mimeType = inferAttachmentPreviewMimeType(relativePath)
const contentUrl = `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(relativePath)}/content`
const attachment: ChatAttachment = {
id: `workspace-file:${target.path}`,
kind: mimeType.startsWith("image/") ? "image" : "file",
displayName: relativePath.split("/").pop() ?? relativePath,
absolutePath: target.path,
relativePath,
contentUrl,
mimeType,
size: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Zero size misclassifies JSON

Workspace attachments always use size: 0, which bypasses the classifier's existing large-JSON guard. JSON files above the preview threshold therefore open in the modal, are truncated by the text preview reader, and can no longer be parsed or formatted as JSON instead of following the large-file new-tab path. Obtain the actual file size before classification or classify workspace files without relying on a fabricated zero size.

Fix in Codex

}
setLocalLinkError(null)
if (classifyAttachmentPreview(attachment).openInNewTab) {
window.open(contentUrl, "_blank", "noopener,noreferrer")
} else {
setLocalFilePreview(attachment)
}
return
}

setLocalLinkError(null)
const action = shouldOpenLocalFileLinkInEditor(target.path) ? "open_editor" : "open_default"
void onOpenLocalLink(target, action)
return
Expand All @@ -935,7 +974,7 @@ const TranscriptScrollerBody = memo(function TranscriptScrollerBody({
view: window,
}))
})
}, [onOpenLocalLink])
}, [localPath, onOpenLocalLink, projectId])

// Stable identity: the viewport commits a render on every scroll event (the
// visible row range changes constantly), and a fresh style object hands the
Expand Down Expand Up @@ -985,9 +1024,9 @@ const TranscriptScrollerBody = memo(function TranscriptScrollerBody({
{!isProcessing && isDraining ? (
<DrainingIndicator onStop={() => void onStopDraining()} />
) : null}
{commandError ? (
{commandError || localLinkError ? (
<div className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{commandError}
{commandError ?? localLinkError}
</div>
) : null}
</div>
Expand Down Expand Up @@ -1043,6 +1082,14 @@ const TranscriptScrollerBody = memo(function TranscriptScrollerBody({
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>

<AttachmentPreviewModal
attachment={localFilePreview}
metadataLabel={localFilePreview?.relativePath}
onOpenChange={(open) => {
if (!open) setLocalFilePreview(null)
}}
/>
</OpenLocalLinkProvider>

{showEmptyState ? null : (
Expand Down
1 change: 1 addition & 0 deletions src/client/app/ChatPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@ export function ChatPage() {
messages={state.messages}
queuedMessages={state.queuedMessages}
transcriptPaddingBottom={transcriptPaddingBottom}
projectId={projectId}
localPath={state.runtime?.localPath}
latestToolIds={state.latestToolIds}
isProcessing={state.isProcessing}
Expand Down
1 change: 1 addition & 0 deletions src/client/app/snapshotEquality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function sameQueuedMessage(left: QueuedChatMessage, right: QueuedChatMessage) {
return left.id === right.id
&& left.content === right.content
&& left.createdAt === right.createdAt
&& left.browserOrigin === right.browserOrigin
&& left.provider === right.provider
&& left.model === right.model
&& left.planMode === right.planMode
Expand Down
4 changes: 4 additions & 0 deletions src/client/app/useSendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type OptimisticUserPrompt,
} from "./kannaStateHelpers"
import type { KannaSocket } from "./socket"
import { browserOriginFromWindow } from "../../shared/browser-context"

export interface SendContext {
isProcessing: boolean
Expand Down Expand Up @@ -65,13 +66,15 @@ export function useSendMessage(params: {
) => {
const { isProcessing, optimisticUserPrompts, serverTranscriptEntries, selectedProjectId, fallbackLocalProjectPath } = sendContextRef.current
const attachments = options?.attachments ?? []
const browserOrigin = browserOriginFromWindow()
if (activeChatId && isProcessing) {
try {
await socket.command<{ queuedMessageId: string }>({
type: "message.enqueue",
chatId: activeChatId,
content,
attachments,
browserOrigin,
provider: options?.provider,
model: options?.model,
modelOptions: options?.modelOptions,
Expand Down Expand Up @@ -139,6 +142,7 @@ export function useSendMessage(params: {
provider: options?.provider,
content,
attachments,
browserOrigin,
model: options?.model,
modelOptions: options?.modelOptions,
planMode: options?.planMode,
Expand Down
8 changes: 5 additions & 3 deletions src/client/components/chat-ui/BrowserPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Copy, CornerDownLeft, Ellipsis, ExternalLink, Globe, GlobeLock, Home, Loader2, Minus, Play, Plus, RefreshCw, SquareArrowOutUpRight, Trash2, Zap } from "lucide-react"
import { memo, useCallback, useEffect, useRef, useState, type FocusEvent, type FormEvent, type ReactNode } from "react"
import type { LocalHttpServerInfo, ProjectQuickAction } from "../../../shared/protocol"
import { browserOriginFromWindow, resolveUrlForBrowserHost } from "../../../shared/browser-context"
import type { KannaSocket } from "../../app/socket"
import {
getCachedLocalHttpServers,
Expand Down Expand Up @@ -139,7 +140,7 @@ function BrowserPanelImpl({ projectId, socket, onRunQuickAction }: BrowserPanelP

const openServer = useCallback(async (server: LocalHttpServerInfo) => {
if (!isCloud) {
navigateBrowser(projectId, server.address)
navigateBrowser(projectId, resolveUrlForBrowserHost(server.address, browserOriginFromWindow()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Loopback links become unreachable

When Kanna is opened through the machine's LAN hostname or IP, this rewrites every discovered loopback URL to that hostname. Discovery does not retain whether the server listens only on loopback, so a server bound to 127.0.0.1 is presented and opened as http://<lan-host>:<port>, where it is unreachable. This also breaks same-machine sessions in which the original localhost URL was usable. Preserve the listener's bind interface and rewrite only servers known to accept network traffic, or retain a usable fallback.

Fix in Codex

return
}
const publicUrl = await exposeServer(server)
Expand Down Expand Up @@ -450,7 +451,8 @@ function BrowserPanelImpl({ projectId, socket, onRunQuickAction }: BrowserPanelP
<div className="space-y-1.5">
{visibleServers.map((server) => {
const isExposing = exposingPorts.has(server.port)
const openUrl = isCloud && server.publicUrl ? server.publicUrl : server.address
const reachableAddress = resolveUrlForBrowserHost(server.address, browserOriginFromWindow())
const openUrl = isCloud && server.publicUrl ? server.publicUrl : reachableAddress
return (
<ContextMenu key={server.address}>
<ContextMenuTrigger asChild>
Expand Down Expand Up @@ -490,7 +492,7 @@ function BrowserPanelImpl({ projectId, socket, onRunQuickAction }: BrowserPanelP
</span>
<span className="flex w-full min-w-0 items-center gap-3">
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{isExposing ? "Exposing…" : server.publicUrl ?? server.address}
{isExposing ? "Exposing…" : server.publicUrl ?? reachableAddress}
</span>
{server.ownerPath ? (
<span className="max-w-[45%] shrink-0 truncate text-right text-[11px] text-muted-foreground/70">{formatPathWithTilde(server.ownerPath)}</span>
Expand Down
5 changes: 3 additions & 2 deletions src/client/components/messages/AttachmentPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ type LoadablePreviewKind = Extract<AttachmentPreviewKind, "markdown" | "text" |

interface Props {
attachment: ChatAttachment | null
metadataLabel?: string
onOpenChange: (open: boolean) => void
}

export function AttachmentPreviewModal({ attachment, onOpenChange }: Props) {
export function AttachmentPreviewModal({ attachment, metadataLabel, onOpenChange }: Props) {
const [previewCache, setPreviewCache] = useState<Record<string, PreviewState>>({})
const previewTarget = useMemo(() => {
return attachment ? classifyAttachmentPreview(attachment) : null
Expand Down Expand Up @@ -149,7 +150,7 @@ export function AttachmentPreviewModal({ attachment, onOpenChange }: Props) {
</DialogBody>
<DialogFooter className="items-center justify-between gap-3 px-4 py-3">
<DialogDescription className="truncate">
{attachment.mimeType} · {formatAttachmentSize(attachment.size)}
{metadataLabel ?? `${attachment.mimeType} · ${formatAttachmentSize(attachment.size)}`}
</DialogDescription>
<div className="flex items-center gap-2">
<DialogGhostButton type="button" onClick={handleCopyLink}>
Expand Down
101 changes: 101 additions & 0 deletions src/client/components/messages/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useEffect, useId, useState } from "react"
import { CopyButton } from "../ui/copy-button"

type MermaidTheme = "default" | "dark"

type DiagramState =
| { status: "loading" }
| { status: "ready"; svg: string }
| { status: "error" }

let renderSequence = 0
let renderQueue = Promise.resolve()

function currentTheme(): MermaidTheme {
if (typeof document === "undefined") return "default"
return document.documentElement.classList.contains("dark") ? "dark" : "default"
}

function queueRender(source: string, id: string, theme: MermaidTheme) {
const render = renderQueue.then(async () => {
const { default: mermaid } = await import("mermaid")
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme,
flowchart: { useMaxWidth: true },
})
return mermaid.render(id, source)
})

renderQueue = render.then(() => undefined, () => undefined)
return render
}

export function MermaidDiagram({ source }: { source: string }) {
const reactId = useId().replace(/[^a-zA-Z0-9_-]/g, "")
const [theme, setTheme] = useState<MermaidTheme>(currentTheme)
const [state, setState] = useState<DiagramState>({ status: "loading" })

useEffect(() => {
const root = document.documentElement
const observer = new MutationObserver(() => setTheme(currentTheme()))
observer.observe(root, { attributes: true, attributeFilter: ["class"] })
return () => observer.disconnect()
}, [])

useEffect(() => {
let cancelled = false
setState({ status: "loading" })
renderSequence += 1

void queueRender(source, `kanna-mermaid-${reactId}-${renderSequence}`, theme)
.then(({ svg }) => {
if (!cancelled) setState({ status: "ready", svg })
})
.catch(() => {
if (!cancelled) setState({ status: "error" })
})

return () => {
cancelled = true
}
}, [reactId, source, theme])

if (state.status === "ready") {
return (
<div
data-mermaid-diagram
role="img"
aria-label="Mermaid diagram"
className="my-3 max-w-full overflow-x-auto rounded-xl border border-border bg-background p-4 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: state.svg }}
/>
)
}

if (state.status === "error") {
return (
<div data-mermaid-diagram className="group/mermaid relative my-3 max-w-full overflow-x-auto rounded-xl border border-border bg-background">
<div className="border-b border-border px-3.5 py-2 text-xs text-muted-foreground">
Unable to render Mermaid diagram. Showing source instead.
</div>
<pre className="min-w-0 px-3.5 py-2.5"><code className="block text-xs whitespace-pre">{source}</code></pre>
<CopyButton
text={source}
className="absolute right-1.5 top-1.5 h-8 w-8 text-muted-foreground opacity-0 transition-opacity group-hover/mermaid:opacity-100"
/>
</div>
)
}

return (
<div
data-mermaid-diagram
aria-label="Rendering Mermaid diagram"
aria-busy="true"
className="my-3 h-40 max-w-full animate-pulse rounded-xl border border-border bg-muted/40"
/>
)
}
10 changes: 10 additions & 0 deletions src/client/components/messages/attachmentPreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
JSON_PREVIEW_LIMIT_BYTES,
classifyAttachmentIcon,
classifyAttachmentPreview,
inferAttachmentPreviewMimeType,
parseDelimitedPreview,
prettifyJson,
} from "./attachmentPreview"
Expand Down Expand Up @@ -55,6 +56,15 @@ describe("classifyAttachmentPreview", () => {
})
})

describe("inferAttachmentPreviewMimeType", () => {
test("recognizes files supported by the in-browser preview", () => {
expect(inferAttachmentPreviewMimeType("README.md")).toBe("text/markdown")
expect(inferAttachmentPreviewMimeType("diagram.svg")).toBe("image/svg+xml")
expect(inferAttachmentPreviewMimeType("app.tsx")).toBe("text/plain")
expect(inferAttachmentPreviewMimeType("archive.zip")).toBe("application/octet-stream")
})
})

describe("parseDelimitedPreview", () => {
test("parses quoted csv cells correctly", () => {
const result = parseDelimitedPreview("name,notes\njake,\"a,b,c\"", ",")
Expand Down
Loading