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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.7.1

### Chat and sidebar

- Navigate long conversations with a prompt rail, prompt previews, and keyboard shortcuts:
`Alt+Up` / `Alt+Down` move between prompts and `Alt+End` jumps to the latest message.
- A scroll-to-end button shows when you are reading earlier messages and highlights new output
without pulling you away from your place.
- Fully hide the sidebar or drag its edge to resize it. The width is remembered; double-click
the resize handle to reset it. Keyboard resizing is also supported.
- Completed turns stop their thinking and response timers reliably, even when delayed Session
reads or stream reconnects arrive after completion. Reconnecting also repairs missed events.

### Preferences

- Appearance, language, and chat preferences are grouped in Settings, with the chat sidebar
still available while Settings is open.
- Open quick preferences beside the Settings button to change theme, accent, font size,
contrast, chat width, and language.
- Adjust chat width continuously from 40% to 100%; the transcript and prompt share the setting,
and existing width preferences carry over.
- Updated English, German, and Spanish translations for the new controls.

## 0.7.0

OpenGUI 0.7.0 brings the release in line with what has been running on managed Hosts, and
Expand Down
2 changes: 1 addition & 1 deletion docker/sandbox/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: "${OPENGUI_SANDBOX_NAME:-opengui-sandbox}"

services:
opengui:
image: "${OPENGUI_IMAGE:-ghcr.io/akemmanuel/opengui:0.7.0}"
image: "${OPENGUI_IMAGE:-ghcr.io/akemmanuel/opengui:0.7.1}"
container_name: "${OPENGUI_SANDBOX_NAME:-opengui-sandbox}"
runtime: "${OPENGUI_SANDBOX_RUNTIME:-runsc}"
restart: unless-stopped
Expand Down
4 changes: 2 additions & 2 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ docker compose up -d
curl --fail http://127.0.0.1:${PORT:-4839}/api/health
```

For this prerelease, select the RC explicitly because prereleases never update `latest`:
To pin the Host to this release instead of following `latest`:

```bash
OPENGUI_IMAGE=ghcr.io/akemmanuel/opengui:0.7.0 docker compose up -d
OPENGUI_IMAGE=ghcr.io/akemmanuel/opengui:0.7.1 docker compose up -d
```

Open `http://127.0.0.1:4839`. The first browser creates the Host owner Account. The compose file
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opengui",
"version": "0.7.0",
"version": "0.7.1",
"private": false,
"description": "OpenGUI - first-party coding agent for desktop and web",
"homepage": "https://github.com/akemmanuel/OpenGUI",
Expand Down
1 change: 1 addition & 0 deletions src/App.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ describe("App shell orchestration", () => {
render(<AppContent onDismissSetup={fixture.dismissSetup} />);
await userEvent.click(screen.getByText("open settings"));
expect(await screen.findByText("settings view")).toBeTruthy();
expect(screen.getByText("open settings")).toBeTruthy();
expect(fixture.dismissSetup).toHaveBeenCalled();
await userEvent.click(screen.getByText("back"));
expect(screen.getByText("transcript")).toBeTruthy();
Expand Down
19 changes: 15 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SectionId } from "@/components/settings/SettingsView";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { QueueList } from "@/components/QueueList";
Expand Down Expand Up @@ -63,6 +64,7 @@ export function AppContent({
}) {
const { t } = useTranslation();
const [chatWidth] = useChatWidth();
const [settingsTab, setSettingsTab] = useState<SectionId | undefined>();
const identityActor = useIdentityActor();
const [activeView, setActiveView] = useState<"chat" | "settings">("chat");
const leftSidebar = useSidebar();
Expand Down Expand Up @@ -181,6 +183,7 @@ export function AppContent({

useEffect(() => {
const openSettings = () => {
setSettingsTab(undefined);
onDismissSetup?.();
setActiveView("settings");
};
Expand All @@ -193,7 +196,8 @@ export function AppContent({
<AppSidebar
detachedProject={detachedProject}
highlightedSessionId={activeView === "chat" ? sessionActiveId : null}
onOpenSettings={() => {
onOpenSettings={(tab) => {
setSettingsTab(tab);
onDismissSetup?.();
setActiveView("settings");
}}
Expand All @@ -202,7 +206,13 @@ export function AppContent({
/>
<SidebarInset className="overflow-hidden">
<div className="flex flex-col h-full">
<TitleBar onToggleLeftSidebar={detachedProject ? undefined : leftSidebar.toggleSidebar} />
<TitleBar
onToggleLeftSidebar={
detachedProject || (leftSidebar.state === "expanded" && !leftSidebar.isMobile)
? undefined
: leftSidebar.toggleSidebar
}
/>

<div className="flex-1 flex flex-col min-w-0 min-h-0 select-none">
{isBooting && (
Expand All @@ -225,7 +235,7 @@ export function AppContent({
</div>
}
>
<SettingsView onBack={() => setActiveView("chat")} />
<SettingsView initialTab={settingsTab} onBack={() => setActiveView("chat")} />
</Suspense>
) : (
<>
Expand All @@ -251,7 +261,8 @@ export function AppContent({
{showPromptBox && !(workspaces.length === 0 && supportsMultipleWorkspaces) && (
<div className="relative z-[1] shrink-0 px-0 md:px-4 app-safe-bottom-inset-prompt">
<div
className={`w-full md:mx-auto ${chatWidth === "full" ? "md:max-w-none" : "md:max-w-2xl"}`}
className="w-full md:mx-auto md:w-[var(--chat-width)]"
style={{ "--chat-width": `${chatWidth}%` } as React.CSSProperties}
>
{queuedPrompts.length > 0 && (
<div className="mb-1.5">
Expand Down
9 changes: 5 additions & 4 deletions src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SectionId } from "@/components/settings/SettingsView";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, X } from "lucide-react";
Expand Down Expand Up @@ -49,7 +50,7 @@ export function AppSidebar({
}: {
detachedProject?: string;
highlightedSessionId?: string | null;
onOpenSettings: () => void;
onOpenSettings: (tab?: SectionId) => void;
onOpenChat: () => void;
settingsActive?: boolean;
}) {
Expand Down Expand Up @@ -426,7 +427,7 @@ export function AppSidebar({
});

return (
<Sidebar collapsible="icon" className="select-none relative">
<Sidebar collapsible="offcanvas" className="select-none">
{profileOpen && <ProfilePanel onClose={() => setProfileOpen(false)} />}
<div
ref={selectionAreaRef}
Expand Down Expand Up @@ -661,9 +662,9 @@ export function AppSidebar({
activeSessionDirectory={activeSessionDirectory}
homeDir={homeDir}
settingsActive={settingsActive}
onOpenSettings={() => {
onOpenSettings={(tab) => {
setProfileOpen(false);
onOpenSettings();
onOpenSettings(tab);
closeMobileSidebar();
}}
profileOpen={profileOpen}
Expand Down
5 changes: 5 additions & 0 deletions src/components/message-list/MessageListTranscript.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useMemo } from "react";
import { MessageListTrailing } from "@/components/message-list/MessageListTrailing";
import type { useMessageListModel } from "@/components/message-list/useMessageListModel";
import { useMessageListTranscriptRows } from "@/features/session-transcript/build-message-list-transcript-rows";
import { buildPromptMarkers } from "@/features/session-transcript/transcript-prompt-markers";
import { TranscriptMessageRow } from "@/features/session-transcript/transcript-rows";
import { TranscriptViewport } from "@/features/session-transcript/transcript-viewport";
import { useActions } from "@/hooks/use-agent-state";
Expand Down Expand Up @@ -50,6 +52,8 @@ export function MessageListTranscript({ model }: { model: MessageListModel }) {
revertToMessage,
});

const promptMarkers = useMemo(() => buildPromptMarkers(rows), [rows]);

const contentKey = `${activeSessionId ?? ""}:${transcriptRevision}:${visibleMessages.length}:${isBusy ? 1 : 0}`;

const trailingContent = (
Expand All @@ -76,6 +80,7 @@ export function MessageListTranscript({ model }: { model: MessageListModel }) {
onLoadOlder={loadOlderMessages}
showLoadOlderRow={messageHistoryHasMore}
trailingContent={trailingContent}
promptMarkers={promptMarkers}
>
{rows.map((row) => (
<TranscriptMessageRow
Expand Down
40 changes: 38 additions & 2 deletions src/components/settings/SettingsView.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ vi.mock("@/features/skills/SkillsSettings", () => ({
}));
vi.mock("@/features/instructions/InstructionsSettings", () => ({
InstructionsSettings: () => <p>instructions-panel</p>,
usePersonalInstructionsAvailable: () => false,
}));
vi.mock("@/features/mcp/McpSettings", () => ({
McpSettings: () => <p>mcp-connections</p>,
Expand All @@ -38,6 +39,14 @@ vi.mock("@/features/identity/workspace-identity", () => ({
identityWorkspaceIsLocalBypass: () => false,
}));

vi.mock("@/features/profile/PreferenceSections", () => ({
AppearanceSettings: () => <p>appearance-controls</p>,
ChatPreferences: () => <p>chat-controls</p>,
}));
vi.mock("@/features/profile/PreferenceControls", () => ({
LanguageOptions: () => <p>language-options</p>,
}));

import { SettingsView } from "./SettingsView";

describe("SettingsView responsive navigation", () => {
Expand All @@ -47,14 +56,32 @@ describe("SettingsView responsive navigation", () => {
render(<SettingsView onBack={vi.fn()} />);
const navigation = screen.getByRole("navigation", { name: "settings.navigationLabel" });
expect(navigation).toBeTruthy();
for (const tab of ["models", "integrations", "paths", "host", "desktop"]) {
for (const tab of [
"appearance",
"language",
"chats",
"models",
"integrations",
"paths",
"host",
"desktop",
]) {
expect(screen.getByRole("button", { name: `settings.tabs.${tab}` })).toBeTruthy();
}
expect(screen.getAllByText("settings.groups.host").length).toBe(1);
expect(screen.queryByText("settings.tabs.general")).toBeNull();

await userEvent.click(screen.getByRole("combobox", { name: "settings.navigationLabel" }));
for (const tab of ["models", "integrations", "paths", "host", "desktop"]) {
for (const tab of [
"appearance",
"language",
"chats",
"models",
"integrations",
"paths",
"host",
"desktop",
]) {
expect(await screen.findByRole("option", { name: `settings.tabs.${tab}` })).toBeTruthy();
}
await userEvent.click(screen.getByRole("option", { name: "settings.tabs.paths" }));
Expand All @@ -77,6 +104,15 @@ describe("SettingsView responsive navigation", () => {
expect(screen.getByText("instructions-panel")).toBeTruthy();
});

test("opens personal preferences and supports the appearance shortcut", async () => {
render(<SettingsView initialTab="appearance" onBack={vi.fn()} />);
expect(screen.getByText("appearance-controls")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "settings.tabs.language" }));
expect(screen.getByText("language-options")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "settings.tabs.chats" }));
expect(screen.getByText("chat-controls")).toBeTruthy();
});

test("opens on the first available section", () => {
render(<SettingsView onBack={vi.fn()} />);
expect(screen.getByText("models-panel")).toBeTruthy();
Expand Down
53 changes: 47 additions & 6 deletions src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import {
Bot,
FolderKey,
Laptop,
Palette,
Languages,
MessageSquare,
Plug,
ScrollText,
Settings2,
Expand Down Expand Up @@ -38,7 +41,11 @@ import {
import { NamedTeamsSettings } from "@/features/identity/NamedTeamsSettings";
import { TeamSettings } from "@/features/identity/TeamSettings";
import { SkillsSettings } from "@/features/skills/SkillsSettings";
import { InstructionsSettings } from "@/features/instructions/InstructionsSettings";
import {
PersonalInstructions,
usePersonalInstructionsAvailable,
InstructionsSettings,
} from "@/features/instructions/InstructionsSettings";
import { McpSettings } from "@/features/mcp/McpSettings";
import { useIdentityActor } from "@/features/identity/identity-actor-context";
import { ownerSettingsVisibility } from "@/features/identity/identity-state";
Expand All @@ -50,7 +57,10 @@ import { cn } from "@/lib/utils";
import { getDesktopShellClient } from "@/runtime/clients";
import packageJson from "../../../package.json";

type SectionId =
export type SectionId =
| "appearance"
| "language"
| "chats"
| "models"
| "integrations"
| "skills"
Expand All @@ -61,12 +71,22 @@ type SectionId =
| "host"
| "desktop";

import { AppearanceSettings, ChatPreferences } from "@/features/profile/PreferenceSections";
import { LanguageOptions } from "@/features/profile/PreferenceControls";

type Section = { id: SectionId; icon: LucideIcon; visible: boolean };
type Group = { id: "agent" | "host" | "device"; sections: Section[] };
type Group = { id: "personal" | "agent" | "host" | "device"; sections: Section[] };

export function SettingsView({ onBack }: { onBack: () => void }) {
export function SettingsView({
onBack,
initialTab,
}: {
onBack: () => void;
initialTab?: SectionId;
}) {
const { t } = useTranslation();
const [requestedTab, setActiveTab] = useState<SectionId | null>(null);
const showPersonalInstructions = usePersonalInstructionsAvailable();
const [requestedTab, setActiveTab] = useState<SectionId | null>(initialTab ?? "models");
const [dirty, setDirty] = useState(false);
const [pendingTab, setPendingTab] = useState<SectionId | "back" | null>(null);

Expand All @@ -83,6 +103,14 @@ export function SettingsView({ onBack }: { onBack: () => void }) {

const groups: Group[] = (
[
{
id: "personal",
sections: [
{ id: "appearance", icon: Palette, visible: true },
{ id: "language", icon: Languages, visible: true },
{ id: "chats", icon: MessageSquare, visible: true },
],
},
{
id: "agent",
sections: [
Expand Down Expand Up @@ -258,10 +286,23 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
{t(`settings.sectionDescriptions.${activeTab}`)}
</p>
</header>
{activeTab === "appearance" && <AppearanceSettings />}
{activeTab === "language" && <LanguageOptions />}
{activeTab === "chats" && <ChatPreferences />}
{activeTab === "models" && <SettingsProviders onDirtyChange={setDirty} />}
{activeTab === "integrations" && <McpSettings />}
{activeTab === "skills" && <SkillsSettings />}
{activeTab === "instructions" && <InstructionsSettings />}
{activeTab === "instructions" && (
<div className="space-y-8">
<InstructionsSettings />
{showPersonalInstructions && (
<section className="space-y-4 border-t pt-6">
<h3 className="font-medium">{t("profile.personalInstructions")}</h3>
<PersonalInstructions onDirty={setDirty} />
</section>
)}
</div>
)}
{activeTab === "users" && <TeamSettings view="people" />}
{activeTab === "teams" && <NamedTeamsSettings onDirtyChange={setDirty} />}
{activeTab === "paths" && (
Expand Down
Loading
Loading