mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
4fb4784b81
Replace the drawer-based mobile layout with a single navigation stack. Home is the session list grouped by status (waiting for you, working, then by day) with the new-task composer docked under it; a session, Settings and subagent or team-member pages push on top, and the system back gesture moves up one level. Touch tablets keep the list beside the page. - Composer: one row (+, model, send); permission mode, context usage and the capability menu move into a + bottom sheet. - Approvals: a waiting permission, question or plan takes the composer's place; the transcript keeps a one-line marker. Denying can carry a reason. - Messages: press and hold for copy, select text, edit and branch instead of an always-visible icon row. - Tool runs fold into one card per run and open the full timeline in a sheet; the turn change card opens a file's diff full screen. - Activity: a pill in the session bar opens the activity sheet instead of auto-opening over the chat. - Settings: one grouped list with the connection, provider, phone-editable preferences and the computer-only settings shown read-only. - Server: GET /api/sessions/live-status reports every running or waiting session so the list can sort sessions the phone never opened.
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
import { memo, useCallback, useMemo, useState } from 'react'
|
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
|
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
|
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
|
|
import { buildOpenWithMenuItemsForHref } from '../../lib/openWithMenuItems'
|
|
import { fileRefFromElement } from '../../lib/markdownAutolink'
|
|
import type { OpenWithItem } from '../../lib/openWithItems'
|
|
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
|
|
import { useMessageActionMenu } from './useMessageActionMenu'
|
|
import { TurnCompletionStamp } from './TurnCompletionStamp'
|
|
import type { TurnCompletion } from '../../lib/turnCompletion'
|
|
import { ImageGalleryModal } from './ImageGalleryModal'
|
|
import { InlineImageGallery } from './InlineImageGallery'
|
|
import { InlineVideoGallery } from './InlineVideoGallery'
|
|
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
|
|
import { openPreviewLink } from '../../lib/openPreviewLink'
|
|
import { extractAssistantOutputTargets, type TurnOutputEvidence } from '../../lib/assistantOutputTargets'
|
|
import { resolveAssistantFileHref } from '@/lib/assistantFileContext'
|
|
import { createAssistantMarkdownImageResolver, localPathFromMarkdownImageUrl } from '../../lib/markdownImages'
|
|
import type { MarkdownImageClick } from '../markdown/MarkdownRenderer'
|
|
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
|
import { isManagedGeneratedImagePath } from '../../lib/attachmentImages'
|
|
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
|
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
|
import { useDiskConfirmedTargets } from '../../hooks/useDiskConfirmedTargets'
|
|
import { useTurnWrittenTargets } from '../../hooks/useTurnWrittenTargets'
|
|
import { createWorkspaceFileLinkVerifier } from '../../lib/workspaceFileStats'
|
|
|
|
type Props = {
|
|
content: string
|
|
isStreaming?: boolean
|
|
branchAction?: MessageBranchAction
|
|
sessionId?: string
|
|
/** This turn's real changed files (absolute), used to anchor output chips onto
|
|
* files that were actually written instead of guessing from the prose. */
|
|
turnChangedFiles?: string[]
|
|
/**
|
|
* What the turn's checkpoint says it could have written. Absent while it is
|
|
* still loading, so no card is shown on a guess that the checkpoint then drops.
|
|
*/
|
|
turnOutputEvidence?: TurnOutputEvidence
|
|
/** Only one assistant message per turn owns fallback cards for unmentioned changed files. */
|
|
isTurnOutputOwner?: boolean
|
|
/** Set only on the last reply of a finished turn: when it ended and how long it took. */
|
|
turnCompletion?: TurnCompletion
|
|
}
|
|
|
|
const MAX_CARDS = 3
|
|
|
|
export const AssistantMessage = memo(function AssistantMessage({
|
|
content,
|
|
isStreaming,
|
|
branchAction,
|
|
sessionId,
|
|
turnChangedFiles,
|
|
turnOutputEvidence,
|
|
isTurnOutputOwner = true,
|
|
turnCompletion,
|
|
}: Props) {
|
|
const t = useTranslation()
|
|
const workDir = useWorkspaceContentStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
|
|
|
|
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null)
|
|
const [viewer, setViewer] = useState<{ images: Array<{ src: string; name: string; path?: string }>; index: number } | null>(null)
|
|
|
|
const handleLinkClick = useCallback(
|
|
(href: string, event: ReactMouseEvent<HTMLDivElement>): boolean => {
|
|
if (!sessionId) return false
|
|
const handled = openPreviewLink(resolveAssistantFileHref(href, content), sessionId)
|
|
if (handled) event.preventDefault()
|
|
return handled
|
|
},
|
|
[content, sessionId],
|
|
)
|
|
|
|
// A reference guessed from a code span or prose links only once its file is
|
|
// known to exist, judged as the click would resolve it — so a name quoted from
|
|
// a commit message never looks openable.
|
|
const fileLinkVerifier = useMemo(
|
|
() => isStreaming || !sessionId
|
|
? undefined
|
|
: createWorkspaceFileLinkVerifier(sessionId, (path) => resolveAssistantFileHref(path, content)),
|
|
[content, isStreaming, sessionId],
|
|
)
|
|
|
|
// Right-clicking a reference in the prose opens the same menu the output cards
|
|
// and the file tree use, so "open in VS Code" / "reveal in Finder" / "copy
|
|
// path" are reachable from the place the model actually names the file.
|
|
const handleContextMenu = useCallback(
|
|
(event: ReactMouseEvent<HTMLDivElement>) => {
|
|
if (!sessionId) return
|
|
const target = event.target as HTMLElement | null
|
|
const link = target?.closest<HTMLAnchorElement>('a[data-file-path], a[href]')
|
|
const href = fileRefFromElement(link) ?? link?.getAttribute('href')
|
|
if (!href) return
|
|
|
|
event.preventDefault()
|
|
const anchor = link!.getBoundingClientRect()
|
|
void (async () => {
|
|
const items = await buildOpenWithMenuItemsForHref(resolveAssistantFileHref(href, content), {
|
|
sessionId,
|
|
workDir,
|
|
// Cast t: useTranslation takes TranslationKey, the builder takes string.
|
|
// Every key it looks up is a valid TranslationKey, so this is safe.
|
|
t: (key, vars) => t(key as TranslationKey, vars),
|
|
})
|
|
if (items.length > 0) setOpenWith({ items, anchor })
|
|
})()
|
|
},
|
|
[content, sessionId, t, workDir],
|
|
)
|
|
|
|
const extractedTargets = useMemo(
|
|
() =>
|
|
isStreaming || !sessionId
|
|
? []
|
|
: // Image/video targets render inline (InlineImageGallery/InlineVideoGallery); never also as a card.
|
|
extractAssistantOutputTargets(content, {
|
|
workDir,
|
|
changedFiles: turnChangedFiles,
|
|
includeChangedFileFallback: isTurnOutputOwner,
|
|
// Confirmed against the disk by useDiskConfirmedTargets before showing.
|
|
includeUnconfirmedNames: true,
|
|
// A card says the turn produced the file; until the checkpoint says
|
|
// what could have written one, nothing unproven is.
|
|
outputEvidence: { unlistedWrites: turnOutputEvidence?.unlistedWrites ?? false },
|
|
}).filter(
|
|
(target) => target.kind !== 'image' && target.kind !== 'video',
|
|
),
|
|
[content, isStreaming, isTurnOutputOwner, sessionId, workDir, turnChangedFiles, turnOutputEvidence?.unlistedWrites],
|
|
)
|
|
// A bare name the text could not bound is settled against the workspace listing,
|
|
// then a file no changed file accounts for must show it was written this turn.
|
|
const settledTargets = useDiskConfirmedTargets(sessionId, extractedTargets)
|
|
const outputTargets = useTurnWrittenTargets(sessionId, settledTargets, turnOutputEvidence?.startedAt)
|
|
const resolveAssistantImageSrc = useMemo(
|
|
() => {
|
|
if (isStreaming || !sessionId) return undefined
|
|
const resolveLocalImage = createAssistantMarkdownImageResolver({
|
|
baseUrl: getServerBaseUrl(),
|
|
sessionId,
|
|
workDir,
|
|
})
|
|
return (src: string) => isManagedGeneratedImagePath(src)
|
|
? null
|
|
: resolveLocalImage(src)
|
|
},
|
|
[isStreaming, sessionId, workDir],
|
|
)
|
|
|
|
// A click on a picture in the prose opens it, and its neighbours in the reply, in
|
|
// the viewer. Each one that is a file on disk carries its path, which is read back
|
|
// from the URL it was served under rather than from anything on the element.
|
|
const handleImageClick = useCallback(
|
|
({ images, index }: MarkdownImageClick) => {
|
|
const baseUrl = getServerBaseUrl()
|
|
setViewer({
|
|
index,
|
|
images: images.map((image) => {
|
|
const path = localPathFromMarkdownImageUrl(image.src, { baseUrl, workDir }) ?? undefined
|
|
const name = image.alt.trim() || path?.split(/[\\/]/).filter(Boolean).pop() || t('assistantOutputs.kind.image')
|
|
return { src: image.src, name, ...(path ? { path } : {}) }
|
|
}),
|
|
})
|
|
},
|
|
[t, workDir],
|
|
)
|
|
|
|
const showTurnCompletion = !isStreaming && Boolean(turnCompletion)
|
|
// On a phone any finished reply can be held for copy and select; branching
|
|
// stays with the reply that closes a turn, as on the desktop bar.
|
|
const actionMenu = useMessageActionMenu({
|
|
copyText: isStreaming ? undefined : content,
|
|
branchAction: showTurnCompletion ? branchAction : undefined,
|
|
})
|
|
|
|
if (!content.trim()) return null
|
|
|
|
const documentLayout = shouldUseDocumentLayout(content)
|
|
|
|
return (
|
|
<div className="flex justify-start">
|
|
<div
|
|
data-message-shell="assistant"
|
|
data-layout={documentLayout ? 'document' : 'bubble'}
|
|
{...actionMenu.pressProps}
|
|
// Always the full column. A reply that hugs its text turns every short
|
|
// answer into a differently-shaped block, so a scrolled transcript reads
|
|
// as a ragged pile; one width makes the replies a single column the eye
|
|
// can run down. The user bubble stays hugged — that asymmetry is what
|
|
// says which side is speaking, so it does not need width to say it too.
|
|
className={`group flex w-full min-w-0 max-w-full flex-col items-start ${actionMenu.pressClassName}`}
|
|
>
|
|
<div
|
|
onContextMenu={sessionId && !actionMenu.enabled ? handleContextMenu : undefined}
|
|
// No card. Left-aligned, full-column prose against the page is already
|
|
// unmistakably the reply — the hugged, tinted bubble on the right is
|
|
// what says who is speaking (see the note above), so a border here
|
|
// repeats that at the cost of ~50px per reply and makes prose look
|
|
// like the tool rows it sits between. The turn rail groups it now.
|
|
className="w-full text-[var(--color-text-primary)]"
|
|
>
|
|
<MarkdownRenderer
|
|
key={`${sessionId ?? ''}|${workDir ?? ''}`}
|
|
className="chat-reading-markdown"
|
|
content={content}
|
|
variant={documentLayout ? 'document' : 'default'}
|
|
streaming={isStreaming}
|
|
onLinkClick={sessionId ? handleLinkClick : undefined}
|
|
resolveImageSrc={resolveAssistantImageSrc}
|
|
onImageClick={resolveAssistantImageSrc ? handleImageClick : undefined}
|
|
fileLinkVerifier={fileLinkVerifier}
|
|
/>
|
|
{!isStreaming && (
|
|
<InlineImageGallery
|
|
text={content}
|
|
sessionId={sessionId}
|
|
workDir={workDir}
|
|
changedFiles={turnChangedFiles}
|
|
suppressManagedGeneratedImages
|
|
/>
|
|
)}
|
|
{!isStreaming && (
|
|
<InlineVideoGallery
|
|
text={content}
|
|
sessionId={sessionId}
|
|
workDir={workDir}
|
|
changedFiles={turnChangedFiles}
|
|
/>
|
|
)}
|
|
{isStreaming && (
|
|
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
|
|
)}
|
|
</div>
|
|
|
|
{!isStreaming && sessionId && outputTargets.length > 0 && (
|
|
<div className="mt-1 flex w-full flex-col gap-2">
|
|
{outputTargets.slice(0, MAX_CARDS).map((target) => (
|
|
<AssistantOutputTargetCard key={target.id} target={target} sessionId={sessionId} workDir={workDir} />
|
|
))}
|
|
{outputTargets.length > MAX_CARDS && (
|
|
<div className="px-1 text-xs text-[var(--color-text-tertiary)]">
|
|
{t('assistantOutputs.moreOutputs', { count: String(outputTargets.length - MAX_CARDS) })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{openWith && (
|
|
<OpenWithMenu
|
|
items={openWith.items}
|
|
anchor={openWith.anchor}
|
|
onClose={() => setOpenWith(null)}
|
|
/>
|
|
)}
|
|
|
|
{viewer && (
|
|
<ImageGalleryModal
|
|
open
|
|
images={viewer.images}
|
|
activeIndex={viewer.index}
|
|
onClose={() => setViewer(null)}
|
|
onSelect={(index) => setViewer((current) => (current ? { ...current, index } : current))}
|
|
/>
|
|
)}
|
|
|
|
{/*
|
|
Only the reply that closes a turn gets this footer. A turn emits many
|
|
intermediate replies ("checking the lint spread now") and nobody copies
|
|
or forks from those — yet the bar reserved 36px on each one whether or
|
|
not it was hovered, which on a one-line reply outweighed the text.
|
|
The action buttons and completion metadata share one compact row so a
|
|
finished answer has one visual close instead of two stacked footers.
|
|
Being rare now, it stays visible instead of waiting for a hover.
|
|
Mid-turn text is still copyable by selecting it (SelectableChatMessage).
|
|
*/}
|
|
{showTurnCompletion && (
|
|
<MessageActionBar
|
|
copyText={actionMenu.enabled ? undefined : content}
|
|
copyLabel={t('chat.copyReply')}
|
|
branchAction={actionMenu.enabled ? undefined : branchAction}
|
|
align="start"
|
|
alwaysVisible
|
|
metadata={<TurnCompletionStamp completion={turnCompletion!} />}
|
|
/>
|
|
)}
|
|
{actionMenu.sheet}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
function shouldUseDocumentLayout(content: string) {
|
|
const normalized = content.trim()
|
|
if (!normalized) return false
|
|
|
|
if (/```/.test(normalized)) return true
|
|
if (/^\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|.+\|)/m.test(normalized)) return true
|
|
|
|
const paragraphs = normalized
|
|
.split(/\n\s*\n/)
|
|
.map((chunk) => chunk.trim())
|
|
.filter(Boolean)
|
|
|
|
return paragraphs.length >= 2 || normalized.split('\n').filter((line) => line.trim()).length >= 8
|
|
}
|