Skip to content
Merged
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
34 changes: 23 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,24 +647,36 @@ express selection — pair it with `bg-surface-selected`.
Line height: UI text uses Tailwind defaults. Long-form reading surfaces
(`markdown.css`, `noteEditor.css`) use 14px / `line-height: 1.75`.

**Measure.** A long-form reading surface is capped at `--reading-measure` (72ch)
and centred:
**Measure.** There are two widths, because a workspace and a reading column are
different things.

A long-form reading surface is capped at `--reading-measure` (72ch) and centred:

```tsx
<div className="mx-auto w-full max-w-[var(--reading-measure)] px-4 py-6">
```

The chat transcript (`MessageList.tsx`) and the note editor (`NoteEditor.tsx`,
`p-4`) both read the token, so a paragraph is the same width on either surface.
Widening a panel adds margin around the column; it must never lengthen the line —
uncapped, a 1000px centre panel sets an answer at roughly 130 characters per
line. The token is defined once in `theme.css`; take it as a variant rather than
The note editor (`NoteEditor.tsx`, `p-4`) is that surface. Widening a panel adds
margin around the column; it must never lengthen the line — uncapped, a 1000px
centre panel sets prose at roughly 130 characters per line.

The chat transcript is not a reading column. Its canvas — the notebook header,
the empty state and the message list in `MessageList.tsx` — is `max-w-5xl`
(~1024px), the same width as the home page, so a wide centre panel is actually
used. Inside that canvas an answer is capped at `--answer-measure` (88ch) rather
than by `--reading-measure`: an answer carries tables and code as often as prose,
and a paragraph at 88ch is still inside a comfortable band. A user message keeps
its narrower bubble. The composer (`ProcessPanel.tsx`) is centred on the same
`max-w-5xl` canvas, so the input lines up with the transcript it answers.

Both tokens are defined once in `theme.css`; take them as variants rather than
hard-coding a `ch` value or a pixel width.

The source reader does **not** read it yet, and the reason is a contract rather
than an oversight: `TextSourceReader` measures its highlight rectangles once from
the laid-out range, so a column that re-centres on resize would strand the
highlight. Make that measurement resize-aware before capping that surface.
The source reader does **not** read `--reading-measure` yet, and the reason is a
contract rather than an oversight: `TextSourceReader` measures its highlight
rectangles once from the laid-out range, so a column that re-centres on resize
would strand the highlight. Make that measurement resize-aware before capping
that surface.

## Accent and states

Expand Down
20 changes: 16 additions & 4 deletions src/renderer/src/assets/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@

/*
* The measure long-form content is set to, in `ch` so it follows the font.
* Every reading surface shares it — the chat transcript and the note editor
* today — so a paragraph is the same width wherever it is read, and widening
* a panel adds margin instead of lengthening the line. 72ch at 14px Inter is
* roughly 600px, inside the 65–75ch band a body copy line wants.
* Every long-form reading surface shares it — the note editor today — so a
* paragraph is the same width wherever it is read, and widening a panel adds
* margin instead of lengthening the line. 72ch at 14px Inter is roughly 600px,
* inside the 65–75ch band a body copy line wants.
*
* The chat transcript is deliberately not one of them: a workspace is wider
* than a reading column, so its canvas is `max-w-5xl` and the answer inside it
* is capped by `--answer-measure`. See DESIGN.md, "Measure".
*
* The source reader is the one surface that does not read this yet: its
* highlight rectangles are measured once from the laid-out range
Expand All @@ -99,6 +103,14 @@
*/
--reading-measure: 72ch;

/*
* The chat answer's measure. Wider than `--reading-measure` because an answer
* carries tables and code as often as prose, and the chat canvas is a workspace
* rather than a page; still capped so a line stays readable when the centre
* panel is dragged wide. 88ch at 14px Inter is roughly 740px.
*/
--answer-measure: 88ch;

--font-sans: 'Inter', sans-serif;
--font-serif: 'Lora', serif;
--font-mono: 'Fira Code', monospace;
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/components/home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default function Home({
if (notebooks.length === 0) {
return (
<ScrollArea className="flex-1">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-6 py-10">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-10">
{header}
<Empty>
<EmptyHeader>
Expand All @@ -119,7 +119,7 @@ export default function Home({

return (
<ScrollArea className="flex-1">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-6 py-10">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
{header}

<HomeSearch
Expand Down
111 changes: 58 additions & 53 deletions src/renderer/src/components/notebook/ProcessPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,60 +318,65 @@ function ProcessPanel({
* content while the transcript keeps its place, because the fade and the
* transcript reserve are both measured from this element.
*/}
<div className="relative bg-surface-overlay rounded-lg border border-border focus-within:ring-2 focus-within:ring-ring shadow-elevation pointer-events-auto select-none">
{/* 范围选择(#94):这次问题用哪些来源回答。放在输入框上方,跟在要提问的地方。 */}
{currentSession && (
<ScopeSelector
scope={scope}
documents={documents}
currentDocumentId={focusedSource?.documentId ?? null}
onChange={(next) => void setSessionScope(currentSession.id, next)}
{/* Centred on the same canvas as the transcript: the input and the answers
it produces line up, so a wide panel adds margin to both instead of
stretching the composer past the text it is answering. */}
<div className="mx-auto w-full max-w-5xl">
<div className="relative bg-surface-overlay rounded-lg border border-border focus-within:ring-2 focus-within:ring-ring shadow-elevation pointer-events-auto select-none">
{/* 范围选择(#94):这次问题用哪些来源回答。放在输入框上方,跟在要提问的地方。 */}
{currentSession && (
<ScopeSelector
scope={scope}
documents={documents}
currentDocumentId={focusedSource?.documentId ?? null}
onChange={(next) => void setSessionScope(currentSession.id, next)}
/>
)}

{/* 多行输入框 */}
<Textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={!hasChatModel ? t('noProviderConfigured') : t('inputMessage')}
disabled={!hasChatModel}
rows={1}
className="w-full bg-transparent border-0 pl-4 pr-14 py-3 text-sm text-foreground placeholder-muted-foreground resize-none focus-visible:ring-0 focus-visible:ring-offset-0 overflow-y-auto min-h-[56px] max-h-[280px] themed-scrollbar select-text"
/>
)}

{/* 多行输入框 */}
<Textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={!hasChatModel ? t('noProviderConfigured') : t('inputMessage')}
disabled={!hasChatModel}
rows={1}
className="w-full bg-transparent border-0 pl-4 pr-14 py-3 text-sm text-foreground placeholder-muted-foreground resize-none focus-visible:ring-0 focus-visible:ring-offset-0 overflow-y-auto min-h-[56px] max-h-[280px] themed-scrollbar select-text"
/>

{/* 发送/停止按钮 - 动态切换 */}
{isCurrentNotebookStreaming ? (
// 停止按钮
<Button
onClick={handleStop}
disabled={!canStop}
title={t('stopGenerating', { ns: 'chat' })}
variant="destructive"
size="icon"
className="absolute right-2 bottom-3 w-8 h-8 rounded-full"
>
<StopCircle className="w-4 h-4" />
</Button>
) : (
// 发送按钮
<Button
onClick={handleSend}
disabled={!canSend}
title={
!hasChatModel
? t('noProviderConfigured')
: !currentSession
? t('selectSession')
: ''
}
size="icon"
className="absolute right-2 bottom-3 w-8 h-8 rounded-full"
>
<Send className="w-4 h-4" />
</Button>
)}

{/* 发送/停止按钮 - 动态切换 */}
{isCurrentNotebookStreaming ? (
// 停止按钮
<Button
onClick={handleStop}
disabled={!canStop}
title={t('stopGenerating', { ns: 'chat' })}
variant="destructive"
size="icon"
className="absolute right-2 bottom-3 w-8 h-8 rounded-full"
>
<StopCircle className="w-4 h-4" />
</Button>
) : (
// 发送按钮
<Button
onClick={handleSend}
disabled={!canSend}
title={
!hasChatModel
? t('noProviderConfigured')
: !currentSession
? t('selectSession')
: ''
}
size="icon"
className="absolute right-2 bottom-3 w-8 h-8 rounded-full"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
</Card>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/notebook/chat/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ function MessageItem({ message }: MessageItemProps): ReactElement {
// AI message: no background, left-aligned, Markdown rendered
return (
<div className="flex justify-start group">
<div className="flex flex-col gap-3 max-w-[85%] min-w-0">
<div className="flex w-full max-w-[var(--answer-measure)] flex-col gap-3 min-w-0">
{/* Reasoning process display - only shown when reasoning content exists */}
{reasoningContent && (
<ReasoningContent content={reasoningContent} isStreaming={reasoningLive} />
Expand Down
8 changes: 3 additions & 5 deletions src/renderer/src/components/notebook/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,12 @@ const MessageList = forwardRef<MessageListHandle, MessageListProps>(function Mes
</div>

<ScrollArea className="h-full" viewportRef={viewportRef}>
{header && (
<div className="mx-auto w-full max-w-[var(--reading-measure)] px-4">{header}</div>
)}
{header && <div className="mx-auto w-full max-w-5xl px-4">{header}</div>}

{!hasMessages ? (
// No `min-h-full`: with the header above it, a full-height box would
// push the empty state down by a viewport and invent a scrollbar.
<div className="mx-auto flex w-full max-w-[var(--reading-measure)] items-center justify-center px-4 py-16">
<div className="mx-auto flex w-full max-w-5xl items-center justify-center px-4 py-16">
<Empty className="border-none">
<EmptyHeader>
<EmptyMedia variant="icon">
Expand All @@ -204,7 +202,7 @@ const MessageList = forwardRef<MessageListHandle, MessageListProps>(function Mes
// event. The reserved space keeps the last line above the composer.
<div
ref={contentRef}
className="mx-auto w-full max-w-[var(--reading-measure)] px-4 py-6"
className="mx-auto w-full max-w-5xl px-4 py-6"
style={{ paddingBottom: COMPOSER_RESERVE }}
>
<div className="space-y-4">
Expand Down
Loading