fix(dashboard): mobile chat New Chat button placement + merged fn-2469
- ChatView mobile: hide the top sidebar header's "New Chat" button on <=768px and show the existing footer button as a pinned full-width action with safe-area padding. Both buttons existed in JSX; only CSS was needed. - styles.css: comments compose/edit textareas use var(--surface) so they remain visible against the task detail modal's --card panel (in light theme --bg and --card both resolve to #ffffff). - Includes resolved-conflict changes from fusion/fn-2469 in ModelOnboardingModal and SettingsModal (and the related test). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -637,6 +637,12 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The mobile-only "New Chat" footer button is hidden on desktop; the
|
||||||
|
desktop layout uses the .chat-sidebar-header button instead. */
|
||||||
|
.chat-sidebar-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Mobile: one pane at a time =========================================
|
/* === Mobile: one pane at a time =========================================
|
||||||
Sidebar (session list) and thread are siblings of .chat-view. On mobile
|
Sidebar (session list) and thread are siblings of .chat-view. On mobile
|
||||||
we show whichever one is "active" full-width: when the sidebar is
|
we show whichever one is "active" full-width: when the sidebar is
|
||||||
@@ -657,4 +663,25 @@
|
|||||||
.chat-sidebar:not(.chat-sidebar--hidden) + .chat-thread {
|
.chat-sidebar:not(.chat-sidebar--hidden) + .chat-thread {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On mobile, the "New Chat" affordance moves to a full-width pinned
|
||||||
|
footer. The desktop top-header button is hidden to free the screen
|
||||||
|
space for the search field and session list. */
|
||||||
|
.chat-sidebar-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar-footer {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
padding-bottom: max(var(--space-sm), env(safe-area-inset-bottom, 0));
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar-footer .chat-sidebar-footer-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -577,6 +577,7 @@ export function ModelOnboardingModal({
|
|||||||
const [ghCliStatus, setGhCliStatus] = useState<GhCliStatus | undefined>(undefined);
|
const [ghCliStatus, setGhCliStatus] = useState<GhCliStatus | undefined>(undefined);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
||||||
|
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -685,6 +686,16 @@ export function ModelOnboardingModal({
|
|||||||
const { providers, ghCli } = await fetchAuthStatus();
|
const { providers, ghCli } = await fetchAuthStatus();
|
||||||
setAuthProviders(providers);
|
setAuthProviders(providers);
|
||||||
setGhCliStatus(ghCli);
|
setGhCliStatus(ghCli);
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const [providerId, instructions] of Object.entries(prev)) {
|
||||||
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
|
if (provider && !provider.authenticated) {
|
||||||
|
next[providerId] = instructions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
|
||||||
|
});
|
||||||
// Remove from skippedProviders when a provider becomes authenticated
|
// Remove from skippedProviders when a provider becomes authenticated
|
||||||
setSkippedProviders((prev) => {
|
setSkippedProviders((prev) => {
|
||||||
const updated = { ...prev };
|
const updated = { ...prev };
|
||||||
@@ -961,13 +972,25 @@ export function ModelOnboardingModal({
|
|||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Set outcome to pending
|
// Set outcome to pending
|
||||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" }));
|
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" }));
|
||||||
setAuthActionInProgress(providerId);
|
setAuthActionInProgress(providerId);
|
||||||
pollCountRef.current = 0;
|
pollCountRef.current = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = await loginProvider(providerId);
|
const { url, instructions } = await loginProvider(providerId);
|
||||||
|
if (instructions?.trim()) {
|
||||||
|
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions }));
|
||||||
|
}
|
||||||
window.open(appendTokenQuery(url), "_blank");
|
window.open(appendTokenQuery(url), "_blank");
|
||||||
|
|
||||||
// Poll for auth completion
|
// Poll for auth completion
|
||||||
@@ -982,6 +1005,14 @@ export function ModelOnboardingModal({
|
|||||||
}
|
}
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
|
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
addToast("Login timed out. Please try again.", "warning");
|
addToast("Login timed out. Please try again.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -998,6 +1029,14 @@ export function ModelOnboardingModal({
|
|||||||
}
|
}
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
|
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
if (providerId === "github") {
|
if (providerId === "github") {
|
||||||
setGitHubSkippedState(false);
|
setGitHubSkippedState(false);
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1060,14 @@ export function ModelOnboardingModal({
|
|||||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
|
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
|
||||||
}
|
}
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addToast, setGitHubSkippedState],
|
[addToast, setGitHubSkippedState],
|
||||||
@@ -1035,6 +1082,14 @@ export function ModelOnboardingModal({
|
|||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
pollCountRef.current = 0;
|
pollCountRef.current = 0;
|
||||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" }));
|
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" }));
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// API key input update handler
|
// API key input update handler
|
||||||
@@ -1633,6 +1688,14 @@ export function ModelOnboardingModal({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{authActionInProgress === provider.id && loginInstructions[provider.id] && (
|
||||||
|
<p
|
||||||
|
className="auth-login-instructions"
|
||||||
|
data-testid={`onboarding-login-instructions-${provider.id}`}
|
||||||
|
>
|
||||||
|
{loginInstructions[provider.id]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
|
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
|
||||||
<p className="onboarding-helper-text onboarding-inline-feedback">
|
<p className="onboarding-helper-text onboarding-inline-feedback">
|
||||||
Login timed out. Please try again.
|
Login timed out. Please try again.
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export function SettingsModal({
|
|||||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
||||||
|
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
|
||||||
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||||
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -288,6 +289,16 @@ export function SettingsModal({
|
|||||||
try {
|
try {
|
||||||
const { providers } = await fetchAuthStatus();
|
const { providers } = await fetchAuthStatus();
|
||||||
setAuthProviders(providers);
|
setAuthProviders(providers);
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const [providerId, instructions] of Object.entries(prev)) {
|
||||||
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
|
if (provider && !provider.authenticated) {
|
||||||
|
next[providerId] = instructions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — auth may not be configured
|
// Silently fail — auth may not be configured
|
||||||
}
|
}
|
||||||
@@ -386,8 +397,20 @@ export function SettingsModal({
|
|||||||
|
|
||||||
const handleLogin = useCallback(async (providerId: string) => {
|
const handleLogin = useCallback(async (providerId: string) => {
|
||||||
setAuthActionInProgress(providerId);
|
setAuthActionInProgress(providerId);
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = await loginProvider(providerId);
|
const { url, instructions } = await loginProvider(providerId);
|
||||||
|
if (instructions?.trim()) {
|
||||||
|
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions }));
|
||||||
|
}
|
||||||
window.open(appendTokenQuery(url), "_blank");
|
window.open(appendTokenQuery(url), "_blank");
|
||||||
|
|
||||||
// Poll for auth completion every 2 seconds
|
// Poll for auth completion every 2 seconds
|
||||||
@@ -402,6 +425,14 @@ export function SettingsModal({
|
|||||||
pollIntervalRef.current = null;
|
pollIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
addToast("Login successful", "success");
|
addToast("Login successful", "success");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -411,6 +442,14 @@ export function SettingsModal({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(getErrorMessage(err) || "Login failed", "error");
|
addToast(getErrorMessage(err) || "Login failed", "error");
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
|
setLoginInstructions((prev) => {
|
||||||
|
if (!(providerId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[providerId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
@@ -3257,6 +3296,14 @@ export function SettingsModal({
|
|||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{loginInstructions[provider.id] && (
|
||||||
|
<p
|
||||||
|
className="auth-login-instructions"
|
||||||
|
data-testid={`auth-login-instructions-${provider.id}`}
|
||||||
|
>
|
||||||
|
{loginInstructions[provider.id]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2928,6 +2928,64 @@ describe("ModelOnboardingModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows OAuth login instructions during pending auth and clears them on cancel", async () => {
|
||||||
|
mockLoginProvider.mockResolvedValueOnce({
|
||||||
|
url: "https://auth.example.com/login",
|
||||||
|
instructions: "Use code WXYZ-9876 to finish authentication.",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Login")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Login"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-login-instructions-anthropic").textContent).toContain("WXYZ-9876");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Cancel"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("onboarding-login-instructions-anthropic")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows GitHub login instructions during connect attempts", async () => {
|
||||||
|
mockFetchAuthStatus.mockImplementation(() => Promise.resolve({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||||
|
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
mockLoginProvider.mockImplementation((providerId: string) => {
|
||||||
|
if (providerId === "github") {
|
||||||
|
return Promise.resolve({
|
||||||
|
url: "https://github.com/login/device",
|
||||||
|
instructions: "Enter device code GH-2469 on github.com/login/device.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ url: "https://auth.example.com/login" });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||||
|
await navigateToGitHubStep();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Connect/ }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("onboarding-login-instructions-github").textContent).toContain("GH-2469");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Cancel"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("onboarding-login-instructions-github")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("login failure shows error toast and sets outcome to failed", async () => {
|
it("login failure shows error toast and sets outcome to failed", async () => {
|
||||||
mockLoginProvider.mockRejectedValueOnce(new Error("Login failed: Invalid credentials"));
|
mockLoginProvider.mockRejectedValueOnce(new Error("Login failed: Invalid credentials"));
|
||||||
|
|
||||||
|
|||||||
@@ -1942,6 +1942,17 @@ input[type="range"]:focus-visible {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.auth-login-instructions {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-info) 35%, var(--border));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--color-info) 8%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
.auth-apikey-section {
|
.auth-apikey-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user