-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat(mobile): make message prose selectable on Android #4549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
66b96c3
fe1b605
4f3e1e8
01bc01d
abc3401
6d1079c
434407f
a7c82dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,19 @@ | |
| "android": { | ||
| "track": "internal" | ||
| } | ||
| }, | ||
| "personal": { | ||
| "ios": { | ||
| "ascAppId": "6793805091", | ||
| "appleTeamId": "C7X9BCC7LP", | ||
| "ascApiKeyPath": "./credentials/ios/AuthKey_5287YYUU8B.p8", | ||
| "ascApiKeyId": "5287YYUU8B", | ||
| "ascApiKeyIssuerId": "69a6de96-923d-47e3-e053-5b8c7c11a4d1" | ||
| }, | ||
| "android": { | ||
| "serviceAccountKeyPath": "./credentials/android/barbellry-45151cd5babe.json", | ||
| "track": "internal" | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personal fork config committedMedium Severity This PR adds a Reviewed by Cursor Bugbot for commit a7c82dd. Configure here. |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { memo, useMemo } from "react"; | ||
| import { Pressable, ScrollView, View } from "react-native"; | ||
|
|
||
| import { SymbolView } from "../../components/AppSymbol"; | ||
| import { AppText as Text } from "../../components/AppText"; | ||
| import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | ||
| import { extractTerminalBufferLinks, terminalLinkLabel } from "./terminalBufferLinks"; | ||
| import type { TerminalTheme } from "./terminalTheme"; | ||
|
|
||
| interface TerminalLinkBarProps { | ||
| readonly buffer: string; | ||
| readonly theme?: Pick<TerminalTheme, "border" | "foreground" | "mutedForeground">; | ||
| } | ||
|
|
||
| const DEFAULT_THEME = { | ||
| border: "rgba(255, 255, 255, 0.1)", | ||
| foreground: "#e5e5e5", | ||
| mutedForeground: "#a3a3a3", | ||
| }; | ||
|
|
||
| /** | ||
| * Tappable chips for URLs printed in the terminal. The Ghostty surface draws | ||
| * text on the GPU without link hit-testing, so this is how terminal URLs | ||
| * (e.g. the `claude` OAuth login URL) are opened on mobile. Tap opens the | ||
| * full reassembled URL; long-press copies it. | ||
| */ | ||
| export const TerminalLinkBar = memo(function TerminalLinkBar(props: TerminalLinkBarProps) { | ||
| const links = useMemo(() => extractTerminalBufferLinks(props.buffer), [props.buffer]); | ||
| const theme = props.theme ?? DEFAULT_THEME; | ||
|
|
||
| if (links.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <View className="border-b" style={{ borderBottomColor: theme.border }}> | ||
| <ScrollView | ||
| horizontal | ||
| showsHorizontalScrollIndicator={false} | ||
| contentContainerClassName="flex-row items-center gap-2 px-3 py-2" | ||
| keyboardShouldPersistTaps="handled" | ||
| > | ||
| {links.map((url) => ( | ||
| <Pressable | ||
| key={url} | ||
| accessibilityRole="link" | ||
| accessibilityLabel={`Open ${url}`} | ||
| accessibilityHint="Long press to copy the link" | ||
| style={({ pressed }) => ({ | ||
| flexDirection: "row", | ||
| alignItems: "center", | ||
| gap: 6, | ||
| borderRadius: 999, | ||
| borderWidth: 1, | ||
| borderColor: theme.border, | ||
| paddingHorizontal: 12, | ||
| paddingVertical: 6, | ||
| opacity: pressed ? 0.6 : 1, | ||
| })} | ||
| onPress={() => void tryOpenExternalUrl(url, "terminal-link")} | ||
| onLongPress={() => copyTextWithHaptic(url, { target: "terminal link" })} | ||
| > | ||
| <SymbolView name="link" size={11} tintColor={theme.mutedForeground} type="monochrome" /> | ||
| <Text className="text-2xs" numberOfLines={1} style={{ color: theme.foreground }}> | ||
| {terminalLinkLabel(url)} | ||
| </Text> | ||
| </Pressable> | ||
| ))} | ||
| </ScrollView> | ||
| </View> | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { | ||
| extractTerminalBufferLinks, | ||
| terminalLinkLabel, | ||
| TERMINAL_LINK_MAX_RESULTS, | ||
| TERMINAL_LINK_SCAN_WINDOW_BYTES, | ||
| } from "./terminalBufferLinks"; | ||
|
|
||
| // Claude Code's OAuth login URL shape: long enough to wrap on any phone. | ||
| const LOGIN_URL = | ||
| "https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile&state=Iv1supDq4Neyq6BB0nWfHhtCU9BuJqz"; | ||
|
|
||
| function wrapToWidth(text: string, width: number): string { | ||
| const lines: string[] = []; | ||
| for (let index = 0; index < text.length; index += width) { | ||
| lines.push(text.slice(index, index + width)); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
|
|
||
| describe("extractTerminalBufferLinks", () => { | ||
| it("finds a plain URL on its own line", () => { | ||
| const buffer = | ||
| "Browser didn't open? Use the url below to sign in:\n\nhttps://example.com/auth\n"; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth"]); | ||
| }); | ||
|
|
||
| it("reassembles a URL hard-wrapped to the terminal width", () => { | ||
| const width = 48; | ||
| const buffer = `Use the url below to sign in:\n\n${wrapToWidth(LOGIN_URL, width)}\n\nPaste code here if prompted:\n`; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]); | ||
| }); | ||
|
|
||
| it("does not glue following prose onto a URL that merely ends a short line", () => { | ||
| const buffer = "Docs: https://example.com/docs\nthanks for reading\n"; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/docs"]); | ||
| }); | ||
|
|
||
| it("ignores ANSI styling wrapped around and inside the URL", () => { | ||
| const buffer = | ||
| "\x1b[2J\x1b[H\x1b[1;34mSign in:\x1b[0m \x1b[4mhttps://example.com/\x1b[36mauth?state=abc\x1b[0m\n"; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth?state=abc"]); | ||
| }); | ||
|
|
||
| it("reassembles a wrapped URL whose rows are painted with cursor-positioning sequences", () => { | ||
| const width = 40; | ||
| const rows = wrapToWidth(LOGIN_URL, width).split("\n"); | ||
| const padded = `Open the link below to continue login now`; | ||
| const buffer = rows | ||
| .map((row, index) => `\x1b[${index + 3};1H\x1b[K${row}`) | ||
| .join("\r\n") | ||
| .concat(`\r\n${padded}\r\n`); | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]); | ||
| }); | ||
|
|
||
| it("extracts OSC 8 hyperlink targets exactly", () => { | ||
| const buffer = | ||
| "See \x1b]8;;https://example.com/very/long/target\x1b\\the docs\x1b]8;;\x1b\\ page\n"; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/very/long/target"]); | ||
| }); | ||
|
|
||
| it("trims trailing punctuation and unbalanced closers", () => { | ||
| const buffer = "Read https://example.com/a). Then https://example.com/b,\n"; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual([ | ||
| "https://example.com/b", | ||
| "https://example.com/a", | ||
| ]); | ||
| }); | ||
|
|
||
| it("drops spinner-truncated prefixes of the same URL, keeping most recent first", () => { | ||
| const buffer = | ||
| "https://example.com/oauth?state=ab\rhttps://example.com/oauth?state=abcdef\nDone. https://example.com/next\n"; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual([ | ||
| "https://example.com/next", | ||
| "https://example.com/oauth?state=abcdef", | ||
| ]); | ||
| }); | ||
|
|
||
| it("caps results and prefers the most recent URLs", () => { | ||
| const buffer = Array.from({ length: 6 }, (_, index) => `https://example.com/${index}`).join( | ||
| "\n", | ||
| ); | ||
| const links = extractTerminalBufferLinks(buffer); | ||
| expect(links).toHaveLength(TERMINAL_LINK_MAX_RESULTS); | ||
| expect(links[0]).toBe("https://example.com/5"); | ||
| }); | ||
|
|
||
| it("ignores a URL cut in half by the scan window", () => { | ||
| const filler = `${"x".repeat(200)}\n`; | ||
| const buffer = `https://truncated.example.com/${"a".repeat(TERMINAL_LINK_SCAN_WINDOW_BYTES)}\n${filler.repeat(4)}https://kept.example.com/ok\n`; | ||
| expect(extractTerminalBufferLinks(buffer)).toEqual(["https://kept.example.com/ok"]); | ||
| }); | ||
|
|
||
| it("returns nothing for an empty or URL-free buffer", () => { | ||
| expect(extractTerminalBufferLinks("")).toEqual([]); | ||
| expect(extractTerminalBufferLinks("$ ls\nsrc package.json\n")).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("terminalLinkLabel", () => { | ||
| it("shows only the host for bare origins", () => { | ||
| expect(terminalLinkLabel("https://claude.ai")).toBe("claude.ai"); | ||
| expect(terminalLinkLabel("https://claude.ai/")).toBe("claude.ai"); | ||
| }); | ||
|
|
||
| it("marks URLs that carry a path or query", () => { | ||
| expect(terminalLinkLabel(LOGIN_URL)).toBe("claude.ai/…"); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
t3code/apps/mobile/app.config.ts
Line 177 in a7c82dd
Setting
T3CODE_EAS_PROJECT_IDchangesextra.eas.projectIdbut leavesupdates.urlhard-coded tohttps://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454, so binaries built for an overridden project query the upstream project's Expo update service and never receive OTA updates published to the configured project.updates.urlmust contain the same project ID, so derive it from the resolved value instead of using a separate hard-coded constant.🤖 Copy this AI Prompt to have your agent fix this: