Composer no longer greys out when empty; tidy sidebar session rows (BUG-1466)
Independent Staging Quality Gate / validate (push) Successful in 22m32s
Independent Staging Quality Gate / publish (push) Canceled after 2m43s

InputGroup's has-disabled: classes matched the disabled send button and
greyed the whole empty composer; ChatComposer cancels both. Sortable
session rows drop the always-on grip column: mouse drags the row (6px),
touch long-presses (250ms), a drag never opens the row, and the grip
stays only for keyboard sorting, visible on focus. The row menu shows on
hover, focus or the current row on hover-capable devices.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
Jesse_Chen
2026-10-27 17:02:54 +08:00
co-authored by Claude Opus 5.5
parent 43c5a81a50
commit 47f6eb4791
10 changed files with 139 additions and 15 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-27 — 输入框不再发灰,会话列表清爽一些
- 首页和对话页的输入框:没打字时不再整块变灰、文字发淡。
- 首页侧栏的会话:去掉每行左边的六点把手,「…」只在鼠标移上去、当前那条或键盘选中时出现(手机上照旧显示)。排序改成按住整行拖动,手机上长按再拖。
- Skill 版本不变(BUG-1466)。
## 2026-10-27 — 星盘页重新排整齐(issue #114 第三轮)
- 北印度盘:每一宫的星座名移到这一宫最宽的外侧,不再在盘中心和交叉点挤成一团;右边的星不再伸出盘外。手机上整张盘完整显示在卡片里。
+16
View File
@@ -19979,3 +19979,19 @@
- 相关记录:issue #114;BUG-1083(手机星盘撑宽,同类:网格/弹性子项最小内容宽)。
- 复发自:无
- 修复版本:分支 `codex/chart-ui-114-20261027` 第三轮。
## BUG-1466 | 首页输入框整块发灰;首页会话列表每行把手加「…」很乱
- 状态:resolved(输入框、列表样子);其他页面会话行没有菜单另见下文,未改
- 首次发现:2026-10-27(issue #114 产品评论,附三张截图)
- 最近更新:2026-10-27
- 影响面:首页和对话页的输入框;首页侧栏会话列表。
- 用户现象:一、输入框外框里面又套了一块灰底圆角块,占位字和模型名都发淡。二、首页侧栏每条会话左边一个六点把手、右边一个「…」,常驻显示,显得乱;进到星盘等其他页面,同一列表只有标题和日期。
- 触发条件:一、输入框为空时(发送按钮是禁用的)。二、首页登录后、会话排序零件载入后。
- 根因:一、组件库 `InputGroup` 带 `has-disabled:bg-input/50 has-disabled:opacity-50`,本意是整个输入框被禁用时变灰;但它匹配的是「里面有任何禁用元素」,空输入框时发送按钮禁用,整块就变灰、半透明。`ChatComposer` 只覆盖了 `bg-transparent`,没覆盖 `has-disabled:` 两项。二、K1(BUG-1433)给可排序的行加了一列常驻 44px 把手,「…」又是 .64 常驻。其他页面没有把手和菜单,是因为会话管理(重命名、删除确认、收藏、转发、排序和它们的乐观更新与回滚)都在首页组件里,只有首页挂着时才登记给外壳(`use-home-shell-registration.ts`);别的页面外壳只拿到一份只读列表,行是链接(TASK-sidebar-unify-20260916 D1 的设计)。
- 修复:一、`ChatComposer` 的 `InputGroup` 加 `has-disabled:bg-transparent has-disabled:opacity-100`。二、可排序的行与其他行同为「标题 + 44px 菜单」两列;鼠标按住整行拖(移动 6px 才算拖),触摸长按 250ms 拖,拖完松手不会误开那条对话;把手改成只在键盘聚焦时出现(键盘排序保留);能悬停的设备上「…」只在悬停、聚焦、当前行和菜单打开时出现,触摸设备仍常驻。其他页面没有菜单这一点本轮未改,要改需把会话管理从首页搬进外壳,待产品决定。
- 验证:`frontend/tests/composer-sidebar-tidy-20261027.test.tsx`(空输入框的 `InputGroup` 带两项覆盖,换回旧 `chat-composer.tsx` 失败;可排序行没有三列标记、把手仍在);`sidebar-contract.test.ts` 改一条断言(三栏说明在测试里)。
- 防复发:上面两条测试。
- 相关记录:BUG-1433(K1 外壳换组件库)、BUG-1453;TASK-sidebar-unify-20260916。
- 复发自:无
- 修复版本:分支 `codex/chart-ui-114-20261027` 第四轮。
+6
View File
@@ -46,3 +46,9 @@
23. 盘卡只有两排控件(盘型、图层);北印度 / 南印度在卡片标题右边。开「虚星」再点「八宫点」,虚星胶囊消失、八宫点数字出现(一次一个)。
24. 南印度盘开「映射」:同一格里的 AL、A11 两枚胶囊不重叠。
25. 宫位主题只有有星的宫;时运流年的日期框不再占满整行。
## 第四轮:输入框与会话列表(BUG-1466)
26. 首页输入框没打字时:外框里没有灰底块,占位字和模型名颜色正常。
27. 电脑首页侧栏:会话行没有左边的六点把手;「…」只在鼠标移上去或当前那条出现。按住一条往上拖到别处松手,顺序变了、没有打开那条对话;只点一下则正常打开。刷新后顺序还在。
28. 手机侧栏:轻扫能正常滚动列表;长按一条再拖可以排序。
+1 -1
View File
@@ -1291,7 +1291,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
## 外壳(2026-10-24)
桌面侧栏宽 200–360px,默认 288。右侧把手是组件库按钮,仍是 6px 透明细条;拖它调整宽度。把手能用 Tab 选中,左右方向键每次 16px,Home / End 到最窄 / 最宽,键盘不会把侧栏收起;读屏读作竖向分隔条,带当前宽度(BUG-1457)。拖到比 200 更窄就收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。侧栏和主区始终在各自的容器里:视口就绪、跨过手机断点,都不会把整页卸掉再挂上。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动和拖动排序用的库在页面出来后再载入,不进首屏。拖宽不再另载一套分栏库。
桌面侧栏宽 200–360px,默认 288。右侧把手是组件库按钮,仍是 6px 透明细条;拖它调整宽度。把手能用 Tab 选中,左右方向键每次 16px,Home / End 到最窄 / 最宽,键盘不会把侧栏收起;读屏读作竖向分隔条,带当前宽度(BUG-1457)。拖到比 200 更窄就收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。侧栏和主区始终在各自的容器里:视口就绪、跨过手机断点,都不会把整页卸掉再挂上。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。可排序的行和其他行长得一样(2026-10-27,BUG-1466):没有常驻把手,鼠标按住整行移动 6px 开始拖,触摸长按 250ms;把手只在键盘聚焦时出现,供键盘排序。能悬停的设备上「…」只在悬停、聚焦、当前行、菜单打开时出现,触摸设备常驻。输入框为空时发送按钮禁用,但输入框本身不变灰(覆盖 `InputGroup` 的 `has-disabled:` 两项)。翻页、高亮滑动和拖动排序用的库在页面出来后再载入,不进首屏。拖宽不再另载一套分栏库。
## 对话页(2026-10-25)
+10 -2
View File
@@ -946,8 +946,11 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i
/* Visual chip stays 32px; wrap gap is 8px, so a 44px overlay would overlap. */
.session-list { min-height: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.sidebar-session { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: stretch; overflow: hidden; border-radius: var(--radius-lg); color: var(--sidebar-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; }
.sidebar-session[data-sortable="true"] { grid-template-columns: 44px minmax(0, 1fr) 44px; }
.session-drag-handle { width: 44px; min-height: 44px; padding: 0; color: inherit; }
/* Sortable rows look like every other row (BUG-1466): mouse and touch drag the row itself; the grip is
only for keyboard sorting and shows only while it has keyboard focus. */
.session-drag-handle { position: absolute; inset: 0 auto 0 0; z-index: 1; width: 16px; min-height: 0; height: auto; padding: 0; color: inherit; opacity: 0; }
.session-drag-handle:focus-visible { opacity: 1; background: var(--sidebar-accent); }
.session-drag-handle > svg { width: 14px; height: 14px; }
.sidebar-session:hover, .sidebar-session:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.sidebar-session:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); }
.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; text-decoration: none; transition: color 120ms ease-out, transform 120ms ease-out; }
@@ -973,6 +976,11 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i
.session-list-sentinel { height: 1px; }
.session-menu-trigger { width: 44px; min-height: 44px; align-self: stretch; display: grid; place-items: center; justify-self: stretch; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: color 120ms ease-out, opacity 120ms ease-out; }
.session-menu-trigger > svg { width: 18px; height: 18px; }
/* With a pointer that hovers, the 「…」 shows on the hovered, focused or current row only; touch keeps it visible. */
@media (hover: hover) {
.session-menu-trigger { opacity: 0; }
.sidebar-session:has(.session-main[data-active="true"]) .session-menu-trigger { opacity: .64; }
}
.sidebar-session:hover .session-menu-trigger, .sidebar-session:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; }
.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: transparent; color: inherit; }
.session-actions-positioner { z-index: 30; max-width: calc(100vw - var(--space-6)); max-height: calc(100dvh - var(--space-6)); outline: 0; }
+3 -1
View File
@@ -100,7 +100,9 @@ export function ChatComposer({
</div>
) : null}
<form className="composer" onSubmit={onSubmit}>
<InputGroup className="border-0 bg-transparent shadow-none ring-0">
{/* `has-disabled:` in InputGroup greys and fades the whole group when any child is disabled, and the
send button is disabled whenever the box is empty (BUG-1466). Only the frame's own disabled look is wanted here. */}
<InputGroup className="border-0 bg-transparent shadow-none ring-0 has-disabled:bg-transparent has-disabled:opacity-100">
<InputGroupTextarea
ref={inputRef}
aria-label={inputLabel}
@@ -8,7 +8,7 @@ import {
StarOff,
Trash2,
} from "lucide-react";
import { forwardRef, type ReactNode } from "react";
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { AppLink } from "@/components/app-link";
import {
DropdownMenu,
@@ -59,7 +59,10 @@ type SidebarSessionRowInteractiveProps = SidebarSessionRowCommonProps & {
readonly onRename: () => void;
readonly onShare: () => void;
readonly onDelete: () => void;
/** Keyboard sorting: a grip that shows only on keyboard focus. */
readonly dragHandle?: ReactNode;
/** Mouse and touch sorting start on the row itself, so the row needs no visible grip. */
readonly dragListeners?: Pick<ButtonHTMLAttributes<HTMLButtonElement>, "onMouseDown" | "onTouchStart">;
};
/**
@@ -117,6 +120,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
onShare,
onDelete,
dragHandle,
dragListeners,
} = props;
return (
@@ -127,7 +131,6 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
>
<div
className="sidebar-session"
data-sortable={dragHandle ? "true" : undefined}
onContextMenu={(event) => {
event.preventDefault();
if (!disabled) onMenuOpenChange(true);
@@ -143,6 +146,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
aria-busy={opening ? true : undefined}
disabled={disabled}
onClick={onSelect}
{...dragListeners}
>
<SessionRowBody session={session} opening={opening} />
</SidebarMenuButton>
@@ -4,11 +4,13 @@ import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { useRef, type ButtonHTMLAttributes, type KeyboardEventHandler } from "react";
import {
SortableContext,
sortableKeyboardCoordinates,
@@ -58,6 +60,9 @@ function SortableSessionRow({
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
id: session.id,
});
// Mouse and touch start on the row; the keyboard one stays on the focus-only grip,
// so Enter on the row still opens the conversation.
const { onKeyDown, ...pointerListeners } = listeners ?? {};
return (
<SidebarMenuItem
ref={setNodeRef}
@@ -76,6 +81,7 @@ function SortableSessionRow({
onRename={onRename}
onShare={onShare}
onDelete={onDelete}
dragListeners={pointerListeners as Pick<ButtonHTMLAttributes<HTMLButtonElement>, "onMouseDown" | "onTouchStart">}
dragHandle={(
<Button
type="button"
@@ -86,7 +92,7 @@ function SortableSessionRow({
disabled={disabled}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
onKeyDown={onKeyDown as KeyboardEventHandler<HTMLButtonElement> | undefined}
>
<GripVertical aria-hidden="true" />
</Button>
@@ -106,12 +112,21 @@ export function SortableSessionGroup({
readonly renderRow: (session: SidebarSession) => RowProps;
}) {
const ids = sessions.map((session) => session.id);
// A drag ends with the pointer released over a row; that click must not open the row.
const dragging = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
// Long-press on touch, so a swipe still scrolls the list.
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function settle() {
window.setTimeout(() => { dragging.current = false; }, 0);
}
function onDragEnd(event: DragEndEvent) {
settle();
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : "";
if (!overId || activeId === overId) return;
@@ -119,11 +134,18 @@ export function SortableSessionGroup({
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={() => { dragging.current = true; }}
onDragCancel={settle}
onDragEnd={onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{sessions.map((session) => {
const row = renderRow(session);
return <SortableSessionRow key={session.id} {...row} />;
const select = row.onSelect;
return <SortableSessionRow key={session.id} {...row} onSelect={() => { if (!dragging.current) select(); }} />;
})}
</SortableContext>
</DndContext>
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { ChatComposer } from "../src/components/chat-composer.tsx";
import { SidebarSessionRow } from "../src/components/sidebar-session-row.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
Object.assign(globalThis, { React });
// BUG-1466: the send button is disabled whenever the box is empty, and InputGroup's `has-disabled:`
// classes then greyed and half-faded the whole input. The composer cancels both.
test("an empty composer is not drawn as a disabled field", () => {
const markup = renderToStaticMarkup(React.createElement(ChatComposer, {
inputRef: { current: null },
value: "",
inputLabel: "输入",
placeholder: "想聊什么都可以",
maxLength: 4000,
inputDisabled: false,
submitLabel: "发送",
submitBlocked: true,
stopVisible: false,
stopLabel: "停止",
stopTitle: "停止",
onSubmit: () => {},
onChange: () => {},
onKeyDown: () => {},
onStop: () => {},
}));
assert.match(markup, /<button[^>]*disabled=""/, "the empty box keeps its send button disabled");
const group = markup.match(/<div[^>]*data-slot="input-group"[^>]*>/)?.[0] ?? "";
assert.match(group, /has-disabled:bg-transparent/);
assert.match(group, /has-disabled:opacity-100/);
});
// BUG-1466: a sortable row is the same two columns as any other row; its grip only serves the keyboard.
test("a sortable session row keeps the plain row shape and forwards drag listeners to the row", () => {
const markup = renderToStaticMarkup(React.createElement(SidebarProvider, null, React.createElement(SidebarSessionRow, {
session: { id: "s1", title: "示例对话", pinned: false, updatedAt: 0 },
active: false,
disabled: false,
menuOpen: false,
onMenuOpenChange: () => {},
onSelect: () => {},
onTogglePinned: () => {},
onRename: () => {},
onShare: () => {},
onDelete: () => {},
dragHandle: React.createElement("button", { type: "button", className: "session-drag-handle" }),
dragListeners: { onMouseDown: () => {} },
})));
assert.match(markup, /class="sidebar-session"/);
assert.doesNotMatch(markup, /data-sortable/);
assert.match(markup, /session-drag-handle/);
});
+7 -4
View File
@@ -572,12 +572,15 @@ test("nests sidebar lists under one heading scale without an archive toggle", ()
});
test("renders each session title and menu as one unified row surface", () => {
// 原值:`.session-row` 两列。
// 新值:`.sidebar-session` 仍是标题加 44px 菜单;可排序时前面再加 44px 手柄。
// 原因:K1 会话行改名,并给拖动排序留出手柄,菜单列还在。
// 原值:可排序时 `.sidebar-session[data-sortable="true"]` 前面加 44px 手柄列(44px 1fr 44px)。
// 新值:可排序的行与其他行同为「标题 + 44px 菜单」两列;手柄绝对定位、只在键盘聚焦时出现,鼠标和触摸拖整行。
// 原因:BUG-1466,产品 10-27 说首页会话列表每行一个把手加「…」很乱,且与其他页面的同一列表长得不一样。
assert.match(cssBlock(".sidebar-session"), /grid-template-columns:\s*minmax\(0,\s*1fr\)\s+44px/);
assert.match(cssBlock(".sidebar-session"), /overflow:\s*hidden/);
assert.match(cssBlock('.sidebar-session[data-sortable="true"]'), /grid-template-columns:\s*44px\s+minmax\(0,\s*1fr\)\s+44px/);
assert.doesNotMatch(globalStyles, /\.sidebar-session\[data-sortable="true"\]\s*\{[^}]*grid-template-columns/);
assert.match(cssBlock(".session-drag-handle"), /position:\s*absolute/);
assert.match(cssBlock(".session-drag-handle"), /opacity:\s*0/);
assert.match(globalStyles, /\.session-drag-handle:focus-visible\s*\{[^}]*opacity:\s*1/);
assert.match(globalStyles, /\.sidebar-session:has\(\.session-main\[data-active="true"\]\)[^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
assert.match(cssBlock(".session-main"), /border-radius:\s*0/);
assert.match(cssBlock('.session-main[data-active="true"]'), /background:\s*transparent/);