🚀 RELEASE: Mobile Responsiveness
This commit is contained in:
parent
ab3ffa17e2
commit
4f523c74a7
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user