🚀 RELEASE: Mobile Responsiveness

This commit is contained in:
kumarabhirup 2026-03-19 15:54:20 -07:00
parent ab3ffa17e2
commit 4f523c74a7
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
13 changed files with 289 additions and 79 deletions

View File

@ -76,12 +76,12 @@ type PanelData = {
function panelColSpan(size?: string): string {
switch (size) {
case "full":
return "col-span-6";
return "col-span-2 sm:col-span-4 lg:col-span-6";
case "third":
return "col-span-2";
return "col-span-1 sm:col-span-2 lg:col-span-2";
case "half":
default:
return "col-span-3";
return "col-span-2 sm:col-span-2 lg:col-span-3";
}
}
@ -298,7 +298,7 @@ export function ReportCard({ config }: ReportCardProps) {
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="grid grid-cols-6 gap-3 p-3">
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3 p-3">
{config.panels.map((panel) => (
<ExpandedPanelCard
key={panel.id}

View File

@ -71,12 +71,12 @@ function buildFilterEntries(
function panelColSpan(size?: string): string {
switch (size) {
case "full":
return "col-span-6";
return "col-span-2 sm:col-span-4 lg:col-span-6";
case "third":
return "col-span-2";
return "col-span-1 sm:col-span-2 lg:col-span-2";
case "half":
default:
return "col-span-3";
return "col-span-2 sm:col-span-2 lg:col-span-3";
}
}
@ -316,8 +316,8 @@ export function ReportViewer({ config: propConfig, reportPath }: ReportViewerPro
)}
{/* Panel grid */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-6 gap-5">
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3 sm:gap-5">
{config.panels.map((panel) => (
<PanelCard
key={panel.id}

View File

@ -732,7 +732,7 @@ function FeedbackButtons({ messageId, sessionId }: { messageId: string; sessionI
const btnBase = "p-1 rounded-md transition-colors";
return (
<div ref={triggerRef} className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div ref={triggerRef} className="flex items-center gap-0.5 mt-1 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => respond("up")}

View File

@ -397,14 +397,14 @@ function QueueItem({
</div>
)}
{!editing && (
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Edit */}
<button
type="button"
className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
title="Edit message"
onClick={() => { setDraft(msg.text); setEditing(true); }}
>
<div className="flex items-center gap-1 shrink-0 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
{/* Edit */}
<button
type="button"
className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
title="Edit message"
onClick={() => { setDraft(msg.text); setEditing(true); }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-stone-400">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
@ -485,7 +485,7 @@ function AttachmentStrip({
onClick={() =>
onRemove(af.id)
}
className="absolute top-1 right-1 z-10 w-[18px] h-[18px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute top-1 right-1 z-10 w-[18px] h-[18px] rounded-full flex items-center justify-center md:opacity-0 md:group-hover:opacity-100 transition-opacity"
style={{
background:
"rgba(0,0,0,0.55)",
@ -2005,7 +2005,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
});
const showStreamActivity = isStreaming && !!streamActivityLabel;
const showHeroState = messages.length === 0 && !compact && !isSubagentMode && !loadingSession;
const showHeroState = messages.length === 0 && (!compact || !fileContext) && !isSubagentMode && !loadingSession;
// ── Input bar content (shared between hero and bottom positions) ──
@ -2371,13 +2371,13 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</div>
</div>
) : (showHeroState && !mounted) ? (
<div className="flex items-center justify-center h-full min-h-[60vh]" />
<div className={`flex items-center justify-center h-full ${compact ? "min-h-[40vh]" : "min-h-[60vh]"}`} />
) : showHeroState ? (
<div className="flex flex-col items-center justify-center min-h-[75vh] py-12">
<div className={`flex flex-col items-center justify-center py-8 md:py-12 ${compact ? "min-h-[60vh]" : "min-h-[75vh]"}`}>
{/* Hero greeting */}
{greeting && (
<h1
className="text-4xl md:text-5xl font-light tracking-normal font-instrument mb-10 text-center"
className="text-3xl md:text-5xl font-light tracking-normal font-instrument mb-6 md:mb-10 text-center px-4"
style={{ color: "var(--color-text)" }}
>
{greeting}
@ -2385,21 +2385,21 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
)}
{/* Centered input bar */}
<div className="w-full max-w-[720px] mx-auto px-4">
<div className="w-full max-w-[720px] mx-auto px-3 md:px-4">
{inputBarContainer(handleInputDragOver, handleInputDragLeave, handleInputDrop)}
</div>
{/* Prompt suggestion pills */}
<div className="mt-6 flex flex-col gap-2.5 w-full max-w-[720px] mx-auto px-4">
<div className="flex items-center justify-center gap-2 flex-wrap">
{visiblePrompts.slice(0, 3).map((template) => {
<div className={`mt-4 md:mt-6 flex flex-col gap-2 md:gap-2.5 w-full max-w-[720px] mx-auto ${compact ? "px-2" : "px-4"}`}>
<div className="flex items-center justify-center gap-2 flex-wrap overflow-x-auto">
{visiblePrompts.slice(0, compact ? 4 : 3).map((template) => {
const Icon = template.icon;
return (
<button
key={template.id}
type="button"
onClick={() => handlePromptClick(template.id)}
className="group flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium whitespace-nowrap rounded-xl transition-all duration-200 border"
className="group flex items-center gap-1.5 px-3 md:px-3.5 py-1.5 md:py-2 text-[11px] md:text-xs font-medium whitespace-nowrap rounded-xl transition-all duration-200 border shrink-0"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
@ -2412,15 +2412,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
);
})}
</div>
<div className="flex items-center justify-center gap-2 flex-wrap">
{visiblePrompts.slice(3, 7).map((template) => {
<div className="flex items-center justify-center gap-2 flex-wrap overflow-x-auto">
{visiblePrompts.slice(compact ? 4 : 3, 7).map((template) => {
const Icon = template.icon;
return (
<button
key={template.id}
type="button"
onClick={() => handlePromptClick(template.id)}
className="group flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium whitespace-nowrap rounded-xl transition-all duration-200 border"
className="group flex items-center gap-1.5 px-3 md:px-3.5 py-1.5 md:py-2 text-[11px] md:text-xs font-medium whitespace-nowrap rounded-xl transition-all duration-200 border shrink-0"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",

View File

@ -1105,7 +1105,7 @@ function JobRow({ job, onClick, onSendCommand }: { job: CronJob; onClick: () =>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
{!job.state.runningAtMs && job.enabled && (
<button type="button" onClick={(e) => { e.stopPropagation(); onSendCommand?.(`Run cron job "${job.name}" (${job.id}) now with --force`); }}
className="p-1 rounded cursor-pointer" style={{ color: "var(--color-accent)" }} title="Run now">

View File

@ -15,13 +15,21 @@ import "@xterm/xterm/css/xterm.css";
const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
const DEFAULT_DRAWER_HEIGHT = 280;
const MOBILE_MAX_DRAWER_HEIGHT_RATIO = 0.6;
const MOBILE_DEFAULT_DRAWER_HEIGHT_RATIO = 0.5;
const STORAGE_KEY = "dench-terminal-height";
const DEFAULT_WS_PORT = 3101;
const MAX_TERMINALS = 8;
function isMobileViewport(): boolean {
if (typeof window === "undefined") return false;
return window.innerWidth < 768;
}
function maxDrawerHeight(): number {
if (typeof window === "undefined") return DEFAULT_DRAWER_HEIGHT;
return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));
const ratio = isMobileViewport() ? MOBILE_MAX_DRAWER_HEIGHT_RATIO : MAX_DRAWER_HEIGHT_RATIO;
return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * ratio));
}
function clampHeight(height: number): number {
@ -31,6 +39,9 @@ function clampHeight(height: number): number {
function loadHeight(): number {
if (typeof window === "undefined") return DEFAULT_DRAWER_HEIGHT;
if (isMobileViewport()) {
return clampHeight(Math.floor(window.innerHeight * MOBILE_DEFAULT_DRAWER_HEIGHT_RATIO));
}
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_DRAWER_HEIGHT;
const parsed = Number(raw);
@ -631,7 +642,7 @@ export default function TerminalDrawer({ onClose, cwd }: TerminalDrawerProps) {
{hasMultiple && (
<button
type="button"
className="inline-flex items-center justify-center rounded opacity-0 group-hover:opacity-100"
className="inline-flex items-center justify-center rounded md:opacity-0 md:group-hover:opacity-100"
style={{
width: 14,
height: 14,

View File

@ -405,10 +405,10 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
}
return (
<div className="flex h-full">
{/* Left panel: Table list */}
<div className="flex flex-col md:flex-row h-full">
{/* Left panel: Table list — sidebar on desktop, horizontal strip on mobile */}
<div
className="w-56 flex-shrink-0 border-r flex flex-col overflow-hidden"
className="hidden md:flex w-56 flex-shrink-0 border-r flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
{/* Database header */}
@ -488,6 +488,47 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
</div>
</div>
{/* Mobile: horizontal table selector + query toggle */}
<div
className="flex md:hidden flex-shrink-0 items-center gap-1.5 px-2 py-1.5 border-b overflow-x-auto"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
{tables.map((t) => {
const isView = t.table_name.startsWith("v_");
const isActive = selectedTable === t.table_name && !queryMode;
return (
<button
key={t.table_name}
type="button"
onClick={() => { setSelectedTable(t.table_name); setPage(0); setQueryMode(false); }}
className="px-2.5 py-1 text-[11px] rounded-full whitespace-nowrap shrink-0 font-medium flex items-center gap-1"
style={{
background: isActive ? "var(--color-accent)" : "var(--color-surface-hover)",
color: isActive ? "white" : "var(--color-text-muted)",
border: isActive ? "none" : "1px solid var(--color-border)",
}}
>
<span className="flex-shrink-0" style={{ color: isActive ? "white" : (isView ? "#60a5fa" : "var(--color-accent)") }}>
{isView ? <ViewIcon /> : <TableIcon />}
</span>
{t.table_name}
</button>
);
})}
<button
type="button"
onClick={() => setQueryMode(!queryMode)}
className="px-2.5 py-1 text-[11px] rounded-full whitespace-nowrap shrink-0 font-medium flex items-center gap-1"
style={{
background: queryMode ? "var(--color-accent)" : "var(--color-surface-hover)",
color: queryMode ? "white" : "var(--color-text-muted)",
border: queryMode ? "none" : "1px solid var(--color-border)",
}}
>
<PlayIcon /> SQL
</button>
</div>
{/* Right panel: Data / Query */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{queryMode ? (

View File

@ -146,7 +146,7 @@ function Dropdown({
<div onClick={() => onOpenChange(!open)}>{trigger}</div>
{open && (
<div
className="absolute z-50 mt-1 rounded-lg shadow-lg border py-1 min-w-[200px] max-h-[320px] overflow-y-auto"
className="absolute z-50 mt-1 rounded-lg shadow-lg border py-1 min-w-[160px] sm:min-w-[200px] max-w-[calc(100vw-2rem)] max-h-[320px] overflow-y-auto"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
@ -215,7 +215,7 @@ function TextValueEditor({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? "Value..."}
className="px-2 py-1 rounded-md text-xs outline-none min-w-[120px]"
className="px-2 py-1 rounded-md text-xs outline-none min-w-[80px] sm:min-w-[120px]"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
@ -776,7 +776,7 @@ export function ObjectFilterBar({
return (
<span
key={rule.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] max-w-[250px] truncate"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] max-w-[180px] sm:max-w-[250px] truncate"
style={{
background: "var(--color-accent-light, rgba(99,102,241,0.1))",
color: "var(--color-accent)",
@ -912,7 +912,7 @@ export function ObjectFilterBar({
e.stopPropagation();
onDeleteView(view.name);
}}
className="px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
className="px-2 py-1 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Delete view"
>

View File

@ -934,7 +934,7 @@ function AddEntryModal({
style={{ background: "rgba(0, 0, 0, 0.5)", backdropFilter: "blur(2px)" }}
>
<div
className="relative mt-12 mb-12 w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl flex flex-col"
className="relative mt-4 mb-4 mx-3 md:mt-12 md:mb-12 md:mx-0 w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl flex flex-col"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",

View File

@ -343,7 +343,7 @@ export function ViewSettingsPopover({
{open && (
<div
className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-lg p-3 min-w-[240px] flex flex-col gap-3"
className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-lg p-3 min-w-[200px] sm:min-w-[240px] max-w-[calc(100vw-2rem)] flex flex-col gap-3"
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",

View File

@ -479,6 +479,8 @@ function WorkspacePageInner() {
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false);
const [sidebarTab, setSidebarTab] = useState<"files" | "chats">("files");
const [chatSidebarOpen, setChatSidebarOpen] = useState(false);
const [mobileChatSessionsOpen, setMobileChatSessionsOpen] = useState(false);
const [mobileFileChatOpen, setMobileFileChatOpen] = useState(false);
// Terminal drawer state
const [terminalOpen, setTerminalOpen] = useState(false);
@ -586,8 +588,12 @@ function WorkspacePageInner() {
return matchingParentTab.id;
}
}
if (tabState.activeTabId === HOME_TAB_ID) {
const blankTab = mainChatTabs.find((tab) => !tab.sessionId && !tab.sessionKey);
if (blankTab) return blankTab.id;
}
return mainChatTabs[0]?.id ?? null;
}, [activeTab, activeSessionId, activeSubagentKey, mainChatTabs]);
}, [activeTab, activeSessionId, activeSubagentKey, mainChatTabs, tabState.activeTabId]);
useEffect(() => {
if (!isChatTab(activeTab)) {
@ -616,8 +622,14 @@ function WorkspacePageInner() {
}, []);
const handleChatTabSessionChange = useCallback((tabId: string, sessionId: string | null) => {
setTabState((prev) => bindParentSessionToChatTab(prev, tabId, sessionId));
if (tabState.activeTabId === tabId || visibleMainChatTabId === tabId) {
setTabState((prev) => {
let next = bindParentSessionToChatTab(prev, tabId, sessionId);
if (sessionId && prev.activeTabId === HOME_TAB_ID) {
next = activateTab(next, tabId);
}
return next;
});
if (tabState.activeTabId === tabId || tabState.activeTabId === HOME_TAB_ID || visibleMainChatTabId === tabId) {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
}
@ -1177,7 +1189,18 @@ function WorkspacePageInner() {
// Tab handler callbacks (defined after loadContent is available)
const handleTabActivate = useCallback((tabId: string) => {
if (tabId === HOME_TAB_ID) {
setTabState((prev) => activateTab(prev, tabId));
setTabState((prev) => {
let next = activateTab(prev, tabId);
const chatTabs = next.tabs.filter((t) => t.id !== HOME_TAB_ID && isChatTab(t));
const hasBlankChat = chatTabs.some((t) => !t.sessionId && !t.sessionKey);
if (!hasBlankChat) {
const blank = createBlankChatTab();
next = { tabs: [...next.tabs, blank], activeTabId: HOME_TAB_ID };
}
return next;
});
setActiveSessionId(null);
setActiveSubagentKey(null);
applyActivatedTab(undefined);
return;
}
@ -2194,43 +2217,125 @@ function WorkspacePageInner() {
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{/* Mobile top bar — always visible on mobile */}
{isMobile && (
<div
className="px-3 py-2 border-b flex-shrink-0 flex items-center justify-between gap-2"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Open sidebar"
<>
<div
className="px-2 py-1.5 border-b flex-shrink-0 flex items-center gap-1.5"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" />
</svg>
</button>
<div className="flex-1 min-w-0 text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")}
</div>
<div className="flex items-center gap-1">
{activePath && content.kind !== "none" && (
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Open sidebar"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" />
</svg>
</button>
<div className="flex-1 min-w-0 text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")}
</div>
<div className="flex items-center gap-0.5">
{activePath && content.kind !== "none" && (
<button
type="button"
onClick={() => {
setActivePath(null);
setContent({ kind: "none" });
}}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Back to chat"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
</svg>
</button>
)}
{!showMainChat && fileContext && (
<button
type="button"
onClick={() => setMobileFileChatOpen(true)}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Chat about this file"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
{showMainChat && (
<button
type="button"
onClick={() => setMobileChatSessionsOpen(true)}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Chat history"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
</button>
)}
<button
type="button"
onClick={() => {
setActivePath(null);
setContent({ kind: "none" });
}}
className="p-2 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Back to chat"
onClick={() => setTerminalOpen((v) => !v)}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: terminalOpen ? "var(--color-text)" : "var(--color-text-muted)", background: terminalOpen ? "var(--color-surface-hover)" : "transparent" }}
title="Terminal"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
<polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" />
</svg>
</button>
)}
<button
type="button"
onClick={() => openBlankChatTab()}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="New chat"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14" /><path d="M5 12h14" />
</svg>
</button>
</div>
</div>
</div>
{/* Mobile tab strip */}
{tabState.tabs.length > 1 && (
<div
className="flex-shrink-0 flex items-center gap-1 px-2 py-1 overflow-x-auto border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
{tabState.tabs.map((tab) => {
const isActive = tab.id === tabState.activeTabId;
const isLive = liveChatTabIds.has(tab.id);
return (
<button
key={tab.id}
type="button"
onClick={() => handleTabActivate(tab.id)}
className="px-2.5 py-1 text-[11px] rounded-full whitespace-nowrap shrink-0 font-medium flex items-center gap-1.5"
style={{
background: isActive ? "var(--color-accent)" : "var(--color-surface-hover)",
color: isActive ? "white" : "var(--color-text-muted)",
border: isActive ? "none" : "1px solid var(--color-border)",
}}
>
{isLive && (
<span className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
)}
<span className="truncate max-w-[120px]">
{tab.title.length > 20 ? tab.title.slice(0, 20) + "..." : tab.title}
</span>
</button>
);
})}
</div>
)}
</>
)}
{/* Tab bar (desktop only, always visible -- home tab is always present) */}
@ -2517,6 +2622,59 @@ function WorkspacePageInner() {
)}
</div>
{/* Mobile chat sessions drawer */}
{isMobile && mobileChatSessionsOpen && (
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
loading={sessionsLoading}
onSelectSession={(sessionId) => {
const session = sessions.find((entry) => entry.id === sessionId);
openSessionChatTab(sessionId, session?.title);
setMobileChatSessionsOpen(false);
}}
onNewSession={() => {
openBlankChatTab();
setMobileChatSessionsOpen(false);
}}
onSelectSubagent={(key) => {
handleSelectSubagent(key);
setMobileChatSessionsOpen(false);
}}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
onStopSession={(sessionId) => { void stopParentSession(sessionId); }}
onStopSubagent={(sessionKey) => { void stopSubagentSession(sessionKey); }}
mobile
width={280}
onClose={() => setMobileChatSessionsOpen(false)}
/>
)}
{/* Mobile file-context chat drawer */}
{isMobile && mobileFileChatOpen && fileContext && (
<div className="drawer-backdrop" onClick={() => setMobileFileChatOpen(false)}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={(e) => e.stopPropagation()} className="fixed inset-y-0 right-0 z-50 drawer-right" style={{ width: "min(85vw, 360px)" }}>
<div className="flex flex-col h-full" style={{ background: "var(--color-bg)" }}>
<ChatPanel
ref={compactChatRef}
compact
fileContext={fileContext}
initialSessionId={fileChatSessionId ?? undefined}
onFileChanged={handleFileChanged}
onFilePathClick={(path) => { handleFilePathClickFromChat(path); setMobileFileChatOpen(false); }}
onActiveSessionChange={setFileChatSessionId}
/>
</div>
</div>
</div>
)}
{/* Terminal drawer (Cmd+J) */}
{terminalOpen && (
<TerminalDrawer onClose={() => setTerminalOpen(false)} cwd={workspaceRoot ?? undefined} />

View File

@ -1,6 +1,6 @@
{
"name": "denchclaw",
"version": "2.3.11",
"version": "2.3.12",
"description": "Fully Managed OpenClaw Framework for managing your CRM, Sales Automation and Outreach agents. The only local productivity tool you need.",
"keywords": [],
"homepage": "https://github.com/DenchHQ/DenchClaw#readme",

View File

@ -1,6 +1,6 @@
{
"name": "dench",
"version": "2.3.11",
"version": "2.3.12",
"description": "Shorthand alias for denchclaw — AI-powered CRM platform CLI",
"license": "MIT",
"repository": {
@ -16,7 +16,7 @@
],
"type": "module",
"dependencies": {
"denchclaw": "^2.3.11"
"denchclaw": "^2.3.12"
},
"engines": {
"node": ">=22.12.0"